mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Follow-up on @TX-Huang's PR #294. The default-agent fallback now routes `jarvis ask "..."` (no --agent) through `config.agent.default_agent`, which dataclass-defaults to `"simple"`. The autouse `_clean_registries` fixture in tests/conftest.py clears AgentRegistry between tests, so the fallback then fails with `Unknown agent: simple` and every CLI-driven test_energy_wiring test exits with code 1. These tests exercise engine-level instrumentation, not agent dispatch — ``test_engine_wrapped_with_instrumented``, the energy-monitor lifecycle tests, and the end-to-end pipeline tests all care that the engine gets wrapped and telemetry lands in SQLite, regardless of whether the call goes through an agent. Set ``cfg.agent.default_agent = ""`` in ``_energy_config`` to keep these tests on the direct-engine path they were originally designed for. The dedicated ``test_agent_mode_uses_instrumented_engine`` (which explicitly passes ``--agent``) is unaffected. Same pattern PR #294 already uses in tests/cli/test_ask_router.py for the two MagicMock-config tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1027 lines
33 KiB
Python
1027 lines
33 KiB
Python
"""Tests for energy telemetry wiring — verify CLI, SDK, bench, and
|
|
telemetry stats all flow through InstrumentedEngine + EnergyMonitor."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import time
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from openjarvis.cli import cli
|
|
from openjarvis.core.config import JarvisConfig
|
|
from openjarvis.core.events import EventBus, EventType
|
|
from openjarvis.core.types import Message, Role, TelemetryRecord
|
|
from openjarvis.telemetry.aggregator import AggregatedStats, TelemetryAggregator
|
|
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
|
|
from openjarvis.telemetry.store import TelemetryStore
|
|
|
|
_ask_mod = importlib.import_module("openjarvis.cli.ask")
|
|
_bench_mod = importlib.import_module("openjarvis.cli.bench_cmd")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mock_engine(content="Test response"):
|
|
"""Return a mock engine that generates a fixed response."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.health.return_value = True
|
|
engine.list_models.return_value = ["test-model"]
|
|
engine.generate.return_value = {
|
|
"content": content,
|
|
"usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 5,
|
|
"total_tokens": 15,
|
|
},
|
|
"model": "test-model",
|
|
"finish_reason": "stop",
|
|
}
|
|
return engine
|
|
|
|
|
|
def _mock_energy_monitor():
|
|
"""Return a mock energy monitor with realistic sample data."""
|
|
monitor = MagicMock()
|
|
monitor.close = MagicMock()
|
|
|
|
sample = MagicMock()
|
|
sample.energy_joules = 42.5
|
|
sample.mean_power_watts = 250.0
|
|
sample.peak_power_watts = 350.0
|
|
sample.mean_utilization_pct = 78.0
|
|
sample.peak_utilization_pct = 95.0
|
|
sample.mean_memory_used_gb = 16.0
|
|
sample.peak_memory_used_gb = 20.0
|
|
sample.mean_temperature_c = 65.0
|
|
sample.peak_temperature_c = 72.0
|
|
sample.duration_seconds = 0.5
|
|
sample.num_snapshots = 10
|
|
sample.energy_method = "hw_counter"
|
|
sample.vendor = "nvidia"
|
|
sample.cpu_energy_joules = 0.0
|
|
sample.gpu_energy_joules = 42.5
|
|
sample.dram_energy_joules = 0.0
|
|
|
|
@contextmanager
|
|
def _sample():
|
|
yield sample
|
|
|
|
monitor.sample = _sample
|
|
return monitor
|
|
|
|
|
|
def _energy_config(tmp_path, gpu_metrics=True):
|
|
"""Build a JarvisConfig with energy monitoring enabled."""
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.enabled = True
|
|
cfg.telemetry.gpu_metrics = gpu_metrics
|
|
cfg.telemetry.energy_vendor = ""
|
|
cfg.telemetry.db_path = str(tmp_path / "telemetry.db")
|
|
# These tests exercise engine-level instrumentation, not agent dispatch.
|
|
# `jarvis ask` (no --agent) now falls back to ``agent.default_agent``
|
|
# which defaults to "simple", and conftest clears the registry per test.
|
|
# Opt out explicitly so the CLI uses direct-engine mode here.
|
|
cfg.agent.default_agent = ""
|
|
return cfg
|
|
|
|
|
|
def _make_energy_record(
|
|
model_id="test-model",
|
|
engine="ollama",
|
|
energy_joules=42.5,
|
|
throughput=120.0,
|
|
gpu_util=78.0,
|
|
power=250.0,
|
|
energy_method="hw_counter",
|
|
energy_vendor="nvidia",
|
|
ts=None,
|
|
):
|
|
"""Create a TelemetryRecord with energy data."""
|
|
return TelemetryRecord(
|
|
timestamp=ts or time.time(),
|
|
model_id=model_id,
|
|
engine=engine,
|
|
prompt_tokens=100,
|
|
completion_tokens=50,
|
|
total_tokens=150,
|
|
latency_seconds=0.5,
|
|
cost_usd=0.0,
|
|
energy_joules=energy_joules,
|
|
power_watts=power,
|
|
gpu_utilization_pct=gpu_util,
|
|
gpu_memory_used_gb=16.0,
|
|
gpu_temperature_c=65.0,
|
|
throughput_tok_per_sec=throughput,
|
|
energy_method=energy_method,
|
|
energy_vendor=energy_vendor,
|
|
gpu_energy_joules=energy_joules,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI ask.py wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCliAskWiring:
|
|
"""Verify cli/ask.py wraps engine with InstrumentedEngine."""
|
|
|
|
def _patch_ask(self, monkeypatch, tmp_path, gpu_metrics=True):
|
|
cfg = _energy_config(tmp_path, gpu_metrics=gpu_metrics)
|
|
monkeypatch.setattr(_ask_mod, "load_config", lambda: cfg)
|
|
|
|
engine = _mock_engine()
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"get_engine",
|
|
lambda *a, **kw: ("mock", engine),
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_engines",
|
|
lambda c: [("mock", engine)],
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_models",
|
|
lambda e: {"mock": ["test-model"]},
|
|
)
|
|
return cfg, engine
|
|
|
|
def test_engine_wrapped_with_instrumented(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""InstrumentedEngine wraps engine, not instrumented_generate."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=False,
|
|
)
|
|
result = CliRunner().invoke(cli, ["ask", "Hello"])
|
|
assert result.exit_code == 0
|
|
assert "Test response" in result.output
|
|
# Engine.generate was called (through InstrumentedEngine)
|
|
engine.generate.assert_called_once()
|
|
|
|
def test_energy_monitor_created_when_gpu_metrics_on(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Energy monitor is created when gpu_metrics=True."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=True,
|
|
)
|
|
mock_monitor = _mock_energy_monitor()
|
|
with patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
):
|
|
result = CliRunner().invoke(cli, ["ask", "Hello"])
|
|
|
|
assert result.exit_code == 0
|
|
mock_monitor.close.assert_called_once()
|
|
|
|
def test_no_energy_monitor_when_gpu_metrics_off(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""No energy monitor when gpu_metrics=False."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=False,
|
|
)
|
|
# Should not attempt to import create_energy_monitor
|
|
result = CliRunner().invoke(cli, ["ask", "Hello"])
|
|
assert result.exit_code == 0
|
|
|
|
def test_telemetry_events_published(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""InstrumentedEngine publishes TELEMETRY_RECORD events."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=False,
|
|
)
|
|
result = CliRunner().invoke(cli, ["ask", "Hello"])
|
|
assert result.exit_code == 0
|
|
# Verify telemetry DB was created and has a record
|
|
db_path = tmp_path / "telemetry.db"
|
|
assert db_path.exists()
|
|
agg = TelemetryAggregator(db_path)
|
|
assert agg.record_count() == 1
|
|
agg.close()
|
|
|
|
def test_energy_data_in_telemetry_record(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Energy data flows into TelemetryRecord in SQLite."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=True,
|
|
)
|
|
mock_monitor = _mock_energy_monitor()
|
|
with patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
):
|
|
CliRunner().invoke(cli, ["ask", "Hello"])
|
|
|
|
db_path = tmp_path / "telemetry.db"
|
|
agg = TelemetryAggregator(db_path)
|
|
records = agg.export_records()
|
|
assert len(records) == 1
|
|
rec = records[0]
|
|
assert rec["energy_joules"] == pytest.approx(42.5)
|
|
assert rec["energy_method"] == "hw_counter"
|
|
assert rec["energy_vendor"] == "nvidia"
|
|
assert rec["gpu_utilization_pct"] == pytest.approx(78.0)
|
|
assert rec["gpu_energy_joules"] == pytest.approx(42.5)
|
|
assert rec["throughput_tok_per_sec"] > 0
|
|
agg.close()
|
|
|
|
def test_agent_mode_uses_instrumented_engine(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Agent mode passes InstrumentedEngine to agent."""
|
|
cfg, engine = self._patch_ask(
|
|
monkeypatch,
|
|
tmp_path,
|
|
gpu_metrics=False,
|
|
)
|
|
|
|
# Register a trivial agent that calls engine.generate
|
|
from openjarvis.agents._stubs import AgentResult
|
|
from openjarvis.core.registry import AgentRegistry
|
|
|
|
class _TestAgent:
|
|
agent_id = "test-wiring-agent"
|
|
|
|
def __init__(self, eng, model, **kw):
|
|
self.engine = eng
|
|
|
|
def run(self, q, context=None, **kw):
|
|
# Call generate to trigger telemetry
|
|
self.engine.generate(
|
|
[Message(role=Role.USER, content=q)],
|
|
model="test-model",
|
|
)
|
|
return AgentResult(content="Agent OK", turns=1)
|
|
|
|
AgentRegistry.register_value(
|
|
"test-wiring-agent",
|
|
_TestAgent,
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["ask", "--agent", "test-wiring-agent", "Hi"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Agent OK" in result.output
|
|
|
|
# Verify telemetry was recorded via InstrumentedEngine
|
|
db_path = tmp_path / "telemetry.db"
|
|
agg = TelemetryAggregator(db_path)
|
|
assert agg.record_count() == 1
|
|
agg.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SDK wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSdkWiring:
|
|
"""Verify sdk.py wraps engine with InstrumentedEngine."""
|
|
|
|
def test_engine_wrapped_in_ensure_engine(self):
|
|
"""_ensure_engine wraps with InstrumentedEngine."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = JarvisConfig()
|
|
with patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
j._ensure_engine()
|
|
assert isinstance(j._engine, InstrumentedEngine)
|
|
j.close()
|
|
|
|
def test_energy_monitor_stored(self, tmp_path):
|
|
"""Energy monitor is created and stored on Jarvis instance."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = _energy_config(tmp_path, gpu_metrics=True)
|
|
mock_monitor = _mock_energy_monitor()
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
j._ensure_engine()
|
|
assert j._energy_monitor is mock_monitor
|
|
j.close()
|
|
mock_monitor.close.assert_called_once()
|
|
|
|
def test_no_energy_monitor_when_gpu_metrics_off(self):
|
|
"""No energy monitor when gpu_metrics=False."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = False
|
|
|
|
with patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
j._ensure_engine()
|
|
assert j._energy_monitor is None
|
|
j.close()
|
|
|
|
def test_ask_full_records_energy(self, tmp_path):
|
|
"""ask_full records energy via InstrumentedEngine."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = _energy_config(tmp_path, gpu_metrics=True)
|
|
mock_monitor = _mock_energy_monitor()
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
result = j.ask_full("Hello")
|
|
assert result["content"] == "Test response"
|
|
j.close()
|
|
|
|
# Verify energy was stored
|
|
agg = TelemetryAggregator(cfg.telemetry.db_path)
|
|
records = agg.export_records()
|
|
assert len(records) == 1
|
|
assert records[0]["energy_joules"] == pytest.approx(42.5)
|
|
assert records[0]["energy_method"] == "hw_counter"
|
|
agg.close()
|
|
|
|
def test_close_cleans_up_energy_monitor(self):
|
|
"""close() releases the energy monitor."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = True
|
|
mock_monitor = _mock_energy_monitor()
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
j._ensure_engine()
|
|
j.close()
|
|
mock_monitor.close.assert_called_once()
|
|
assert j._energy_monitor is None
|
|
|
|
def test_double_close_safe(self):
|
|
"""Double close doesn't crash."""
|
|
from openjarvis.sdk import Jarvis
|
|
|
|
engine = _mock_engine()
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = True
|
|
mock_monitor = _mock_energy_monitor()
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.sdk.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
),
|
|
):
|
|
j = Jarvis(config=cfg, model="test-model")
|
|
j._ensure_engine()
|
|
j.close()
|
|
j.close() # should not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# InstrumentedEngine + EnergyMonitor integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInstrumentedEngineEnergy:
|
|
"""Verify InstrumentedEngine correctly uses EnergyMonitor."""
|
|
|
|
def test_energy_monitor_sample_called(self):
|
|
"""Energy monitor's sample() is invoked during generate."""
|
|
engine = _mock_engine()
|
|
bus = EventBus(record_history=True)
|
|
monitor = _mock_energy_monitor()
|
|
|
|
ie = InstrumentedEngine(
|
|
engine,
|
|
bus,
|
|
energy_monitor=monitor,
|
|
)
|
|
messages = [Message(role=Role.USER, content="Hi")]
|
|
result = ie.generate(messages, model="test")
|
|
|
|
assert result["content"] == "Test response"
|
|
# Verify energy data is in the telemetry record
|
|
tel_events = [
|
|
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
|
|
]
|
|
assert len(tel_events) == 1
|
|
rec = tel_events[0].data["record"]
|
|
assert rec.energy_joules == pytest.approx(42.5)
|
|
assert rec.energy_method == "hw_counter"
|
|
assert rec.energy_vendor == "nvidia"
|
|
assert rec.gpu_utilization_pct == pytest.approx(78.0)
|
|
assert rec.gpu_energy_joules == pytest.approx(42.5)
|
|
|
|
def test_energy_data_injected_into_result(self):
|
|
"""_telemetry dict in result contains energy fields."""
|
|
engine = _mock_engine()
|
|
bus = EventBus()
|
|
monitor = _mock_energy_monitor()
|
|
|
|
ie = InstrumentedEngine(
|
|
engine,
|
|
bus,
|
|
energy_monitor=monitor,
|
|
)
|
|
messages = [Message(role=Role.USER, content="Hi")]
|
|
result = ie.generate(messages, model="test")
|
|
|
|
assert "_telemetry" in result
|
|
telem = result["_telemetry"]
|
|
assert telem["energy_joules"] == pytest.approx(42.5)
|
|
assert telem["energy_method"] == "hw_counter"
|
|
assert telem["energy_vendor"] == "nvidia"
|
|
assert telem["gpu_utilization_pct"] == pytest.approx(78.0)
|
|
assert telem["gpu_energy_joules"] == pytest.approx(42.5)
|
|
assert telem["cpu_energy_joules"] == 0.0
|
|
assert telem["dram_energy_joules"] == 0.0
|
|
|
|
def test_no_energy_monitor_still_works(self):
|
|
"""Without energy_monitor, generate still works with zeros."""
|
|
engine = _mock_engine()
|
|
bus = EventBus(record_history=True)
|
|
|
|
ie = InstrumentedEngine(engine, bus)
|
|
messages = [Message(role=Role.USER, content="Hi")]
|
|
result = ie.generate(messages, model="test")
|
|
|
|
assert result["content"] == "Test response"
|
|
tel = [e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD]
|
|
rec = tel[0].data["record"]
|
|
assert rec.energy_joules == 0.0
|
|
assert rec.energy_method == ""
|
|
|
|
def test_energy_monitor_failure_graceful(self):
|
|
"""If energy monitor sample raises, generate still works."""
|
|
engine = _mock_engine()
|
|
bus = EventBus()
|
|
monitor = MagicMock()
|
|
|
|
@contextmanager
|
|
def _broken_sample():
|
|
raise RuntimeError("GPU fell off")
|
|
yield # pragma: no cover
|
|
|
|
monitor.sample = _broken_sample
|
|
|
|
ie = InstrumentedEngine(
|
|
engine,
|
|
bus,
|
|
energy_monitor=monitor,
|
|
)
|
|
messages = [Message(role=Role.USER, content="Hi")]
|
|
# Should not crash — energy is best-effort.
|
|
# InstrumentedEngine tries energy_monitor first, falls
|
|
# through to no-monitor path on exception.
|
|
# Note: current impl doesn't catch, so this tests that
|
|
# the engine call path is resilient.
|
|
with pytest.raises(RuntimeError, match="GPU fell off"):
|
|
ie.generate(messages, model="test")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bench CLI wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBenchWiring:
|
|
"""Verify bench CLI creates and passes energy_monitor."""
|
|
|
|
def test_energy_monitor_passed_to_benchmarks(self):
|
|
"""When gpu_metrics=True, energy_monitor is passed."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.list_models.return_value = ["test-model"]
|
|
engine.generate.return_value = {
|
|
"content": "Hello",
|
|
"usage": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 3,
|
|
"total_tokens": 8,
|
|
},
|
|
}
|
|
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = True
|
|
mock_monitor = _mock_energy_monitor()
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.load_config",
|
|
return_value=cfg,
|
|
),
|
|
patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
) as mock_create,
|
|
):
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["bench", "run", "-n", "2"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_create.assert_called_once()
|
|
mock_monitor.close.assert_called_once()
|
|
|
|
def test_no_energy_monitor_when_gpu_metrics_off(self):
|
|
"""No energy_monitor when gpu_metrics=False."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.list_models.return_value = ["test-model"]
|
|
engine.generate.return_value = {
|
|
"content": "Hello",
|
|
"usage": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 3,
|
|
"total_tokens": 8,
|
|
},
|
|
}
|
|
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = False
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.load_config",
|
|
return_value=cfg,
|
|
),
|
|
):
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["bench", "run", "-n", "2"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
def test_warmup_flag_passed(self):
|
|
"""--warmup flag is forwarded to benchmarks."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.list_models.return_value = ["test-model"]
|
|
engine.generate.return_value = {
|
|
"content": "Hello",
|
|
"usage": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 3,
|
|
"total_tokens": 8,
|
|
},
|
|
}
|
|
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.gpu_metrics = False
|
|
|
|
with (
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.get_engine",
|
|
return_value=("mock", engine),
|
|
),
|
|
patch(
|
|
"openjarvis.cli.bench_cmd.load_config",
|
|
return_value=cfg,
|
|
),
|
|
):
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["bench", "run", "-n", "2", "-w", "3"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Telemetry stats wiring — energy columns in output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _populate_energy_db(db_path: Path, n: int = 3) -> None:
|
|
"""Create a telemetry DB with energy-enriched records."""
|
|
store = TelemetryStore(db_path)
|
|
for i in range(n):
|
|
store.record(
|
|
_make_energy_record(
|
|
model_id=f"model-{i % 2}",
|
|
energy_joules=10.0 * (i + 1),
|
|
throughput=100.0 + i * 10,
|
|
gpu_util=70.0 + i * 5,
|
|
power=200.0 + i * 25,
|
|
ts=time.time() - (n - i),
|
|
)
|
|
)
|
|
store.close()
|
|
|
|
|
|
def _patch_telemetry_config(tmp_path: Path):
|
|
"""Patch load_config for telemetry CLI."""
|
|
db_path = tmp_path / "telemetry.db"
|
|
cfg = mock.MagicMock()
|
|
cfg.telemetry.db_path = str(db_path)
|
|
return mock.patch(
|
|
"openjarvis.cli.telemetry_cmd.load_config",
|
|
return_value=cfg,
|
|
), db_path
|
|
|
|
|
|
class TestTelemetryStatsEnergy:
|
|
"""Verify telemetry stats shows energy columns."""
|
|
|
|
def test_energy_columns_in_stats(self, tmp_path):
|
|
"""Stats output includes energy metrics when data exists."""
|
|
p, db_path = _patch_telemetry_config(tmp_path)
|
|
_populate_energy_db(db_path)
|
|
with p:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "stats"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Total Energy (J)" in result.output
|
|
assert "Avg Throughput" in result.output
|
|
assert "Avg GPU Utilization" in result.output
|
|
# Per-model table
|
|
assert "Energy (J)" in result.output
|
|
assert "Throughput" in result.output
|
|
# Rich may wrap "GPU Util %" across lines
|
|
assert "GPU Util" in result.output
|
|
|
|
def test_no_energy_columns_when_no_energy_data(self, tmp_path):
|
|
"""Stats hides energy columns when no energy data."""
|
|
p, db_path = _patch_telemetry_config(tmp_path)
|
|
# Populate with non-energy records
|
|
store = TelemetryStore(db_path)
|
|
for i in range(3):
|
|
store.record(
|
|
TelemetryRecord(
|
|
timestamp=time.time(),
|
|
model_id="model-0",
|
|
engine="ollama",
|
|
prompt_tokens=10,
|
|
completion_tokens=5,
|
|
total_tokens=15,
|
|
latency_seconds=0.5,
|
|
cost_usd=0.001,
|
|
)
|
|
)
|
|
store.close()
|
|
|
|
with p:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "stats"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Total Calls" in result.output
|
|
# Energy columns should NOT appear
|
|
assert "Total Energy" not in result.output
|
|
assert "GPU Util" not in result.output
|
|
|
|
def test_export_includes_energy_fields(self, tmp_path):
|
|
"""JSON export includes all energy fields."""
|
|
p, db_path = _patch_telemetry_config(tmp_path)
|
|
_populate_energy_db(db_path, n=1)
|
|
with p:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "export", "-f", "json"],
|
|
)
|
|
assert result.exit_code == 0
|
|
data = json.loads(result.output)
|
|
assert len(data) == 1
|
|
rec = data[0]
|
|
assert "energy_joules" in rec
|
|
assert "energy_method" in rec
|
|
assert "energy_vendor" in rec
|
|
assert "gpu_energy_joules" in rec
|
|
assert "cpu_energy_joules" in rec
|
|
assert "dram_energy_joules" in rec
|
|
assert "throughput_tok_per_sec" in rec
|
|
assert "gpu_utilization_pct" in rec
|
|
assert rec["energy_joules"] == pytest.approx(10.0)
|
|
assert rec["energy_method"] == "hw_counter"
|
|
assert rec["energy_vendor"] == "nvidia"
|
|
|
|
def test_csv_export_has_energy_headers(self, tmp_path):
|
|
"""CSV export includes energy column headers."""
|
|
p, db_path = _patch_telemetry_config(tmp_path)
|
|
_populate_energy_db(db_path, n=1)
|
|
with p:
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "export", "-f", "csv"],
|
|
)
|
|
assert result.exit_code == 0
|
|
header = result.output.strip().splitlines()[0]
|
|
assert "energy_joules" in header
|
|
assert "energy_method" in header
|
|
assert "energy_vendor" in header
|
|
assert "gpu_energy_joules" in header
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Aggregator energy fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAggregatorEnergy:
|
|
"""Verify AggregatedStats includes energy aggregations."""
|
|
|
|
def test_aggregated_stats_has_energy_fields(self):
|
|
"""AggregatedStats dataclass has energy attributes."""
|
|
s = AggregatedStats()
|
|
assert s.total_energy_joules == 0.0
|
|
assert s.avg_throughput_tok_per_sec == 0.0
|
|
assert s.avg_gpu_utilization_pct == 0.0
|
|
|
|
def test_summary_computes_energy_totals(self, tmp_path):
|
|
"""summary() sums energy and computes weighted averages."""
|
|
db_path = tmp_path / "telemetry.db"
|
|
store = TelemetryStore(db_path)
|
|
store.record(
|
|
_make_energy_record(
|
|
model_id="m1",
|
|
energy_joules=10.0,
|
|
throughput=100.0,
|
|
gpu_util=80.0,
|
|
)
|
|
)
|
|
store.record(
|
|
_make_energy_record(
|
|
model_id="m1",
|
|
energy_joules=20.0,
|
|
throughput=120.0,
|
|
gpu_util=90.0,
|
|
)
|
|
)
|
|
store.record(
|
|
_make_energy_record(
|
|
model_id="m2",
|
|
energy_joules=30.0,
|
|
throughput=80.0,
|
|
gpu_util=60.0,
|
|
)
|
|
)
|
|
store.close()
|
|
|
|
agg = TelemetryAggregator(db_path)
|
|
s = agg.summary()
|
|
|
|
assert s.total_calls == 3
|
|
assert s.total_energy_joules == pytest.approx(60.0)
|
|
# Weighted avg throughput: (110*2 + 80*1) / 3 = 100
|
|
assert s.avg_throughput_tok_per_sec == pytest.approx(100.0)
|
|
# Weighted avg GPU util: (85*2 + 60*1) / 3 = 76.67
|
|
assert s.avg_gpu_utilization_pct == pytest.approx(
|
|
76.666,
|
|
rel=0.01,
|
|
)
|
|
agg.close()
|
|
|
|
def test_per_model_stats_energy(self, tmp_path):
|
|
"""per_model_stats includes energy fields."""
|
|
db_path = tmp_path / "telemetry.db"
|
|
store = TelemetryStore(db_path)
|
|
store.record(
|
|
_make_energy_record(
|
|
model_id="m1",
|
|
energy_joules=50.0,
|
|
)
|
|
)
|
|
store.close()
|
|
|
|
agg = TelemetryAggregator(db_path)
|
|
stats = agg.per_model_stats()
|
|
assert len(stats) == 1
|
|
assert stats[0].total_energy_joules == pytest.approx(50.0)
|
|
assert stats[0].avg_gpu_utilization_pct == pytest.approx(78.0)
|
|
assert stats[0].avg_throughput_tok_per_sec == pytest.approx(
|
|
120.0,
|
|
)
|
|
agg.close()
|
|
|
|
def test_per_engine_stats_energy(self, tmp_path):
|
|
"""per_engine_stats includes energy fields."""
|
|
db_path = tmp_path / "telemetry.db"
|
|
store = TelemetryStore(db_path)
|
|
store.record(
|
|
_make_energy_record(
|
|
engine="vllm",
|
|
energy_joules=25.0,
|
|
)
|
|
)
|
|
store.close()
|
|
|
|
agg = TelemetryAggregator(db_path)
|
|
stats = agg.per_engine_stats()
|
|
assert len(stats) == 1
|
|
assert stats[0].total_energy_joules == pytest.approx(25.0)
|
|
agg.close()
|
|
|
|
def test_empty_summary_energy_zero(self, tmp_path):
|
|
"""Empty DB has zero energy in summary."""
|
|
db_path = tmp_path / "telemetry.db"
|
|
store = TelemetryStore(db_path)
|
|
store.close()
|
|
|
|
agg = TelemetryAggregator(db_path)
|
|
s = agg.summary()
|
|
assert s.total_energy_joules == 0.0
|
|
assert s.avg_throughput_tok_per_sec == 0.0
|
|
assert s.avg_gpu_utilization_pct == 0.0
|
|
agg.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: CLI ask -> TelemetryStore -> TelemetryAggregator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEndToEndPipeline:
|
|
"""Full pipeline: ask → InstrumentedEngine → energy → SQLite → stats."""
|
|
|
|
def test_ask_to_stats_with_energy(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Full flow: ask records energy, stats displays it."""
|
|
cfg = _energy_config(tmp_path, gpu_metrics=True)
|
|
engine = _mock_engine()
|
|
|
|
monkeypatch.setattr(_ask_mod, "load_config", lambda: cfg)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"get_engine",
|
|
lambda *a, **kw: ("mock", engine),
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_engines",
|
|
lambda c: [("mock", engine)],
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_models",
|
|
lambda e: {"mock": ["test-model"]},
|
|
)
|
|
|
|
mock_monitor = _mock_energy_monitor()
|
|
with patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
):
|
|
CliRunner().invoke(cli, ["ask", "Hello"])
|
|
|
|
# Now verify stats shows energy
|
|
telem_cfg = mock.MagicMock()
|
|
telem_cfg.telemetry.db_path = cfg.telemetry.db_path
|
|
with mock.patch(
|
|
"openjarvis.cli.telemetry_cmd.load_config",
|
|
return_value=telem_cfg,
|
|
):
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "stats"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Total Energy (J)" in result.output
|
|
assert "42.50" in result.output # energy_joules value
|
|
|
|
def test_ask_to_export_with_energy(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Full flow: ask records energy, export includes it."""
|
|
cfg = _energy_config(tmp_path, gpu_metrics=True)
|
|
engine = _mock_engine()
|
|
|
|
monkeypatch.setattr(_ask_mod, "load_config", lambda: cfg)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"get_engine",
|
|
lambda *a, **kw: ("mock", engine),
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_engines",
|
|
lambda c: [("mock", engine)],
|
|
)
|
|
monkeypatch.setattr(
|
|
_ask_mod,
|
|
"discover_models",
|
|
lambda e: {"mock": ["test-model"]},
|
|
)
|
|
|
|
mock_monitor = _mock_energy_monitor()
|
|
with patch(
|
|
"openjarvis.telemetry.energy_monitor.create_energy_monitor",
|
|
return_value=mock_monitor,
|
|
):
|
|
CliRunner().invoke(cli, ["ask", "Hello"])
|
|
|
|
# Export as JSON
|
|
telem_cfg = mock.MagicMock()
|
|
telem_cfg.telemetry.db_path = cfg.telemetry.db_path
|
|
with mock.patch(
|
|
"openjarvis.cli.telemetry_cmd.load_config",
|
|
return_value=telem_cfg,
|
|
):
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["telemetry", "export", "-f", "json"],
|
|
)
|
|
|
|
data = json.loads(result.output)
|
|
assert len(data) == 1
|
|
assert data[0]["energy_joules"] == pytest.approx(42.5)
|
|
assert data[0]["energy_method"] == "hw_counter"
|