mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
Consolidates five contributor PRs into one bundle to avoid overlapping issues (notably #340/#341 both touching mcp/transport.py + agent_cmd.py + executor.py). Original authorship preserved on every commit. See PR #367 for full per-author breakdown. Credits: @Dilligaf371 (#340, #341), @eddierichter-amd (#301), @kriptoburak (#347), @bootcrowns (#202).
This commit is contained in:
@@ -116,7 +116,7 @@ All providers produce the same output format consumed by agents:
|
||||
| **LM Studio** | `lmstudio` | OpenAI-compatible | 1234 | No (GPU optional) | Desktop GUI, easy model management |
|
||||
| **Exo** | `exo` | OpenAI-compatible | 52415 | No (distributed) | Distributed inference across heterogeneous devices |
|
||||
| **Nexa** | `nexa` | OpenAI-compatible | 18181 | No (CPU/GPU) | On-device inference with GGUF models |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 8000 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 13305 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Uzu** | `uzu` | OpenAI-compatible | 8000 | Varies | Uzu inference runtime |
|
||||
| **Apple FM** | `apple_fm` | OpenAI-compatible | 8079 | Apple Silicon | Apple Foundation Model on-device inference |
|
||||
| **LiteLLM** | `litellm` | OpenAI-compatible | — | No | Unified proxy to 100+ LLM providers |
|
||||
@@ -135,7 +135,7 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and
|
||||
|
||||
The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (NVIDIA A100, H100, L40, A10, A30 and AMD MI300, MI325, MI350, MI355).
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools
|
||||
|
||||
@@ -204,7 +204,7 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA
|
||||
|
||||
The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inference server, which is optimized for AMD consumer GPUs (RDNA architecture) and Ryzen AI Neural Processing Units (NPUs). It uses the OpenAI-compatible `/v1/chat/completions` API.
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** Visit [lemonade-server.ai](https://lemonade-server.ai/) for platform-specific installation instructions
|
||||
- **Best for:** Ryzen AI GPUs and NPUs, and AMD-based desktop and laptop systems
|
||||
@@ -357,7 +357,7 @@ host = "http://localhost:30000"
|
||||
# binary_path = ""
|
||||
|
||||
# [engine.lemonade]
|
||||
# host = "http://localhost:8000"
|
||||
# host = "http://localhost:13305"
|
||||
```
|
||||
|
||||
The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings:
|
||||
@@ -370,7 +370,7 @@ The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settin
|
||||
| `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL |
|
||||
| `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL |
|
||||
| `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:8000` | Lemonade server URL |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:13305` | Lemonade server URL |
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`, and `lemonade_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format.
|
||||
|
||||
@@ -148,6 +148,14 @@ jarvis skill sync openclaw --search "web3|crypto"
|
||||
jarvis skill install github:user/repo/path/to/skill --url https://github.com/user/repo
|
||||
```
|
||||
|
||||
For example, install the Hermes Tweet skill when you want an agent to search
|
||||
Twitter/X, read tweet replies, monitor tweets, export followers, and run
|
||||
gated post, reply, or DM workflows:
|
||||
|
||||
```bash
|
||||
jarvis skill install github:Xquik-dev/hermes-tweet/skills/hermes-tweet --url https://github.com/Xquik-dev/hermes-tweet
|
||||
```
|
||||
|
||||
### Config-Driven Auto Import
|
||||
|
||||
Add sources to `~/.openjarvis/config.toml` for automatic syncing:
|
||||
|
||||
@@ -317,6 +317,24 @@ class AgentExecutor:
|
||||
tool_instances.append(tool)
|
||||
except Exception:
|
||||
logger.warning("Failed to instantiate tool %s", tname)
|
||||
|
||||
# Pull tools already discovered by SystemBuilder (e.g. external MCP
|
||||
# adapters) that aren't in the static ToolRegistry. Without this,
|
||||
# agents declaring MCP-discovered tools in their template would
|
||||
# silently fall back to natives only.
|
||||
if (
|
||||
self._system is not None
|
||||
and getattr(self._system, "tool_executor", None) is not None
|
||||
):
|
||||
mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {}
|
||||
existing = {t.spec.name for t in tool_instances}
|
||||
for tname in tool_names:
|
||||
if tname in existing:
|
||||
continue
|
||||
pooled = mcp_pool.get(tname)
|
||||
if pooled is not None:
|
||||
tool_instances.append(pooled)
|
||||
|
||||
if tool_instances:
|
||||
logger.info(
|
||||
"Agent %s: resolved %d/%d tools",
|
||||
@@ -332,6 +350,12 @@ class AgentExecutor:
|
||||
agent_kwargs["system_prompt"] = sys_prompt
|
||||
if getattr(agent_cls, "accepts_tools", False) and tool_instances:
|
||||
agent_kwargs["tools"] = tool_instances
|
||||
# Propagate confirmation policy from the AgentExecutor down to the
|
||||
# agent's own ToolExecutor. Set by CLI paths like `jarvis agents ask`
|
||||
# so non-interactive runs can auto-approve tool execution.
|
||||
if getattr(self, "_confirm_callback", None) is not None:
|
||||
agent_kwargs["interactive"] = True
|
||||
agent_kwargs["confirm_callback"] = self._confirm_callback
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
except TypeError:
|
||||
|
||||
@@ -702,16 +702,34 @@ def errors():
|
||||
@agent.command("ask")
|
||||
@click.argument("agent_id")
|
||||
@click.argument("message")
|
||||
def ask(agent_id, message):
|
||||
@click.option(
|
||||
"--yes/--no-yes",
|
||||
"auto_approve",
|
||||
default=True,
|
||||
help="Auto-approve tool execution that would otherwise need confirmation. "
|
||||
"Default: on (suits non-interactive CLI use). Pass --no-yes to require a "
|
||||
"TTY prompt for tools whose ToolSpec sets requires_confirmation=True.",
|
||||
)
|
||||
def ask(agent_id, message, auto_approve):
|
||||
"""Ask an agent a question (immediate response)."""
|
||||
manager = _get_manager()
|
||||
agent_id = _resolve_agent_id(manager, agent_id)
|
||||
manager.send_message(agent_id, message, mode="immediate")
|
||||
click.echo("Asking agent...")
|
||||
_, executor, _ = _get_scheduler_and_executor()
|
||||
_, executor, system = _get_scheduler_and_executor()
|
||||
if executor is None:
|
||||
click.echo("Executor not available", err=True)
|
||||
raise SystemExit(1)
|
||||
# Wire a confirmation callback so the agent's own ToolExecutor can actually
|
||||
# run tools whose ToolSpec sets requires_confirmation=True (e.g. shell_exec,
|
||||
# git_*). `executor` is the AgentExecutor; the callback is read in
|
||||
# _invoke_agent and propagated to the constructed agent via agent_kwargs.
|
||||
if auto_approve:
|
||||
executor._confirm_callback = lambda _prompt: True
|
||||
else:
|
||||
executor._confirm_callback = (
|
||||
lambda prompt: click.confirm(f"\n{prompt}", default=False)
|
||||
)
|
||||
executor.execute_tick(agent_id)
|
||||
msgs = manager.list_messages(agent_id)
|
||||
responses = [m for m in msgs if m["direction"] == "agent_to_user"]
|
||||
|
||||
@@ -280,14 +280,14 @@ _MODEL_TIERS = [
|
||||
(64, "qwen3.5:27b"),
|
||||
]
|
||||
_MODEL_TIER_FALLBACK = "qwen3.5:27b"
|
||||
_LEMONADE_DEFAULT_MODEL = "Qwen3.6-35B-A3B-GGUF"
|
||||
|
||||
|
||||
def recommend_model(hw: HardwareInfo, engine: str) -> str:
|
||||
"""Suggest the best Qwen3.5 model that fits the detected hardware.
|
||||
"""Suggest a default model for the selected engine and hardware.
|
||||
|
||||
Uses an explicit tier table mapping available memory to model size.
|
||||
Falls back to scanning the full catalog if the tiered model is not
|
||||
compatible with the selected engine.
|
||||
For Lemonade, prefer the validated Qwen3.6 35B A3B GGUF default.
|
||||
For other local engines, use the generic Qwen3.5 tier mapping.
|
||||
"""
|
||||
from openjarvis.intelligence.model_catalog import BUILTIN_MODELS
|
||||
|
||||
@@ -295,6 +295,9 @@ def recommend_model(hw: HardwareInfo, engine: str) -> str:
|
||||
if available_gb <= 0:
|
||||
return ""
|
||||
|
||||
if engine == "lemonade":
|
||||
return _LEMONADE_DEFAULT_MODEL
|
||||
|
||||
# Build a lookup for quick engine-compatibility checks
|
||||
catalog = {spec.model_id: spec for spec in BUILTIN_MODELS}
|
||||
|
||||
@@ -422,7 +425,7 @@ class GemmaCppEngineConfig:
|
||||
class LemonadeEngineConfig:
|
||||
"""Per-engine config for Lemonade."""
|
||||
|
||||
host: str = "http://localhost:8000"
|
||||
host: str = "http://localhost:13305"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -13,7 +13,7 @@ _ENGINES = {
|
||||
"nexa": ("NexaEngine", "http://localhost:18181", "/v1"),
|
||||
"uzu": ("UzuEngine", "http://localhost:8000", ""),
|
||||
"apple_fm": ("AppleFmEngine", "http://localhost:8079", "/v1"),
|
||||
"lemonade": ("LemonadeEngine", "http://localhost:8000", "/v1"),
|
||||
"lemonade": ("LemonadeEngine", "http://localhost:13305", "/v1"),
|
||||
}
|
||||
|
||||
for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items():
|
||||
|
||||
@@ -84,11 +84,15 @@ class MCPClient:
|
||||
"""
|
||||
response = self._send("tools/list")
|
||||
tools = response.result.get("tools", [])
|
||||
# MCP tools often wrap long-running pentest/scan commands. The default
|
||||
# ToolSpec timeout (30s) kills them mid-scan. Bump to 600s — individual
|
||||
# MCP servers can shorten via their own protocol if needed.
|
||||
return [
|
||||
ToolSpec(
|
||||
name=t["name"],
|
||||
description=t.get("description", ""),
|
||||
parameters=t.get("inputSchema", {}),
|
||||
timeout_seconds=600.0,
|
||||
)
|
||||
for t in tools
|
||||
]
|
||||
|
||||
@@ -13,7 +13,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import pynvml
|
||||
|
||||
_PYNVML_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYNVML_AVAILABLE = False
|
||||
@@ -23,7 +22,6 @@ except ImportError:
|
||||
# Hardware spec database
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuHardwareSpec:
|
||||
"""Peak theoretical capabilities for a known GPU model."""
|
||||
@@ -50,6 +48,26 @@ GPU_SPECS: Dict[str, GpuHardwareSpec] = {
|
||||
# Apple Silicon
|
||||
"M4 Max": GpuHardwareSpec(tflops_fp16=53, bandwidth_gb_s=546, tdp_watts=40),
|
||||
"M2 Ultra": GpuHardwareSpec(tflops_fp16=27, bandwidth_gb_s=800, tdp_watts=60),
|
||||
# Intel Arc
|
||||
"Arc B580": GpuHardwareSpec(tflops_fp16=196, bandwidth_gb_s=456, tdp_watts=190),
|
||||
"Arc B570": GpuHardwareSpec(tflops_fp16=136, bandwidth_gb_s=380, tdp_watts=150),
|
||||
# NVIDIA Jetson
|
||||
"Jetson Orin NX 16GB": GpuHardwareSpec(
|
||||
tflops_fp16=50, bandwidth_gb_s=102, tdp_watts=25
|
||||
),
|
||||
"Jetson Orin NX 8GB": GpuHardwareSpec(
|
||||
tflops_fp16=25, bandwidth_gb_s=68, tdp_watts=15
|
||||
),
|
||||
"Jetson AGX Orin": GpuHardwareSpec(
|
||||
tflops_fp16=108, bandwidth_gb_s=204, tdp_watts=60
|
||||
),
|
||||
# Qualcomm
|
||||
"Snapdragon X Elite": GpuHardwareSpec(
|
||||
tflops_fp16=4.6, bandwidth_gb_s=136, tdp_watts=80
|
||||
),
|
||||
"Snapdragon X Plus": GpuHardwareSpec(
|
||||
tflops_fp16=3.8, bandwidth_gb_s=136, tdp_watts=80
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +88,6 @@ def lookup_gpu_spec(name: str) -> Optional[GpuHardwareSpec]:
|
||||
# Snapshot & aggregated sample
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class GpuSnapshot:
|
||||
"""A single point-in-time reading from one GPU device."""
|
||||
@@ -103,7 +120,6 @@ class GpuSample:
|
||||
# Monitor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GpuMonitor:
|
||||
"""Background GPU poller using pynvml.
|
||||
|
||||
@@ -219,6 +235,7 @@ class GpuMonitor:
|
||||
mean_util = sum(s.utilization_pct for s in tick_snaps) / len(tick_snaps)
|
||||
total_mem = sum(s.memory_used_gb for s in tick_snaps)
|
||||
mean_temp = sum(s.temperature_c for s in tick_snaps) / len(tick_snaps)
|
||||
|
||||
tick_powers.append(total_power)
|
||||
tick_utils.append(mean_util)
|
||||
tick_mems.append(total_mem)
|
||||
@@ -256,7 +273,6 @@ class GpuMonitor:
|
||||
:class:`GpuSample` without starting a background thread.
|
||||
"""
|
||||
result = GpuSample()
|
||||
|
||||
if not self._initialized or self._device_count == 0:
|
||||
t_start = time.monotonic()
|
||||
yield result
|
||||
@@ -276,11 +292,13 @@ class GpuMonitor:
|
||||
|
||||
t_start = time.monotonic()
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
yield result
|
||||
finally:
|
||||
stop_event.set()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
wall = time.monotonic() - t_start
|
||||
|
||||
with lock:
|
||||
|
||||
@@ -37,10 +37,12 @@ class TestDefaults:
|
||||
assert ec.vllm.host == "http://localhost:8000"
|
||||
assert ec.sglang.host == "http://localhost:30000"
|
||||
assert ec.llamacpp.host == "http://localhost:8080"
|
||||
assert ec.lemonade.host == "http://localhost:13305"
|
||||
assert ec.llamacpp.binary_path == ""
|
||||
# Backward-compat properties still work
|
||||
assert ec.ollama_host == ""
|
||||
assert ec.vllm_host == "http://localhost:8000"
|
||||
assert ec.lemonade_host == "http://localhost:13305"
|
||||
|
||||
|
||||
class TestRecommendEngine:
|
||||
@@ -93,6 +95,17 @@ class TestTomlLoading:
|
||||
assert cfg.engine.default == "vllm"
|
||||
assert cfg.memory.default_backend == "faiss"
|
||||
|
||||
def test_loads_nested_lemonade_host_override(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[engine]\ndefault = "lemonade"\n\n'
|
||||
'[engine.lemonade]\nhost = "http://custom-lemonade:19000"\n'
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.engine.default == "lemonade"
|
||||
assert cfg.engine.lemonade.host == "http://custom-lemonade:19000"
|
||||
assert cfg.engine.lemonade_host == "http://custom-lemonade:19000"
|
||||
|
||||
|
||||
class TestGenerateToml:
|
||||
def test_contains_engine_section(self) -> None:
|
||||
@@ -213,6 +226,7 @@ class TestNestedEngineConfig:
|
||||
assert ec.vllm.host == "http://localhost:8000"
|
||||
assert ec.sglang.host == "http://localhost:30000"
|
||||
assert ec.llamacpp.host == "http://localhost:8080"
|
||||
assert ec.lemonade.host == "http://localhost:13305"
|
||||
assert ec.llamacpp.binary_path == ""
|
||||
|
||||
def test_backward_compat_setter(self) -> None:
|
||||
@@ -250,6 +264,17 @@ class TestNestedEngineConfig:
|
||||
assert cfg.engine.ollama.host == "http://old:11434"
|
||||
assert cfg.engine.vllm.host == "http://old:8000"
|
||||
|
||||
def test_loads_old_flat_lemonade_host(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[engine]\ndefault = "lemonade"\n'
|
||||
'lemonade_host = "http://legacy-lemonade:19191"\n'
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.engine.default == "lemonade"
|
||||
assert cfg.engine.lemonade.host == "http://legacy-lemonade:19191"
|
||||
assert cfg.engine.lemonade_host == "http://legacy-lemonade:19191"
|
||||
|
||||
|
||||
class TestNestedLearningConfig:
|
||||
def test_defaults(self) -> None:
|
||||
|
||||
@@ -86,6 +86,20 @@ class TestRecommendModelGpu:
|
||||
# available = 288 GB → tier fallback qwen3.5:27b, valid for vllm
|
||||
assert result == "qwen3.5:27b"
|
||||
|
||||
def test_amd_lemonade_picks_qwen36_35b_a3b(self) -> None:
|
||||
hw = HardwareInfo(
|
||||
platform="linux",
|
||||
ram_gb=64.0,
|
||||
gpu=GpuInfo(
|
||||
vendor="amd",
|
||||
name="Radeon RX 7900 XTX",
|
||||
vram_gb=24.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
result = recommend_model(hw, "lemonade")
|
||||
assert result == "Qwen3.6-35B-A3B-GGUF"
|
||||
|
||||
|
||||
class TestRecommendModelEdgeCases:
|
||||
"""Edge cases."""
|
||||
|
||||
@@ -32,7 +32,7 @@ _OPENAI_COMPAT_ENGINES = [
|
||||
("nexa", "http://testhost:18181", NexaEngine),
|
||||
("uzu", "http://testhost:8000", UzuEngine),
|
||||
("apple_fm", "http://testhost:8079", AppleFmEngine),
|
||||
("lemonade", "http://testhost:8000", LemonadeEngine),
|
||||
("lemonade", "http://testhost:13305", LemonadeEngine),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from openjarvis.engine.openai_compat_engines import LemonadeEngine
|
||||
@pytest.fixture()
|
||||
def engine() -> LemonadeEngine:
|
||||
EngineRegistry.register_value("lemonade", LemonadeEngine)
|
||||
return LemonadeEngine(host="http://testhost:8000")
|
||||
return LemonadeEngine(host="http://testhost:13305")
|
||||
|
||||
|
||||
class TestLemonadeEngineBasics:
|
||||
@@ -27,7 +27,7 @@ class TestLemonadeEngineBasics:
|
||||
assert LemonadeEngine.engine_id == "lemonade"
|
||||
|
||||
def test_default_host(self) -> None:
|
||||
assert LemonadeEngine._default_host == "http://localhost:8000"
|
||||
assert LemonadeEngine._default_host == "http://localhost:13305"
|
||||
|
||||
def test_api_prefix(self) -> None:
|
||||
assert LemonadeEngine._api_prefix == "/v1"
|
||||
@@ -36,11 +36,22 @@ class TestLemonadeEngineBasics:
|
||||
EngineRegistry.register_value("lemonade", LemonadeEngine)
|
||||
assert EngineRegistry.get("lemonade") is LemonadeEngine
|
||||
|
||||
def test_env_var_overrides_default_host(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LEMONADE_HOST", "http://env-lemonade:17777")
|
||||
engine = LemonadeEngine()
|
||||
try:
|
||||
assert engine._host == "http://env-lemonade:17777"
|
||||
finally:
|
||||
engine.close()
|
||||
|
||||
|
||||
class TestLemonadeGenerate:
|
||||
def test_generate_uses_v1_prefix(self, engine: LemonadeEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:8000/v1/chat/completions").mock(
|
||||
respx.post("http://testhost:13305/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
@@ -67,7 +78,7 @@ class TestLemonadeGenerate:
|
||||
|
||||
def test_generate_connection_error(self, engine: LemonadeEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:8000/v1/chat/completions").mock(
|
||||
respx.post("http://testhost:13305/v1/chat/completions").mock(
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
with pytest.raises(EngineConnectionError):
|
||||
@@ -80,14 +91,14 @@ class TestLemonadeGenerate:
|
||||
class TestLemonadeHealth:
|
||||
def test_health_true(self, engine: LemonadeEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.get("http://testhost:8000/v1/models").mock(
|
||||
respx.get("http://testhost:13305/v1/models").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
assert engine.health() is True
|
||||
|
||||
def test_health_false(self, engine: LemonadeEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.get("http://testhost:8000/v1/models").mock(
|
||||
respx.get("http://testhost:13305/v1/models").mock(
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
assert engine.health() is False
|
||||
@@ -96,7 +107,7 @@ class TestLemonadeHealth:
|
||||
class TestLemonadeListModels:
|
||||
def test_list_models_uses_v1_prefix(self, engine: LemonadeEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.get("http://testhost:8000/v1/models").mock(
|
||||
respx.get("http://testhost:13305/v1/models").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
|
||||
@@ -14,7 +14,6 @@ import pytest
|
||||
# Helpers: build a fake pynvml module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeUtilization:
|
||||
gpu: int = 80
|
||||
@@ -60,7 +59,6 @@ def _snap(power=300, util=80, mem=12, temp=65, dev=0):
|
||||
# Tests: GpuHardwareSpec lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGpuHardwareSpec:
|
||||
def test_lookup_exact_key(self):
|
||||
from openjarvis.telemetry.gpu_monitor import lookup_gpu_spec
|
||||
@@ -107,6 +105,13 @@ class TestGpuHardwareSpec:
|
||||
"MI250X",
|
||||
"M4 Max",
|
||||
"M2 Ultra",
|
||||
"Arc B580",
|
||||
"Arc B570",
|
||||
"Jetson Orin NX 16GB",
|
||||
"Jetson Orin NX 8GB",
|
||||
"Jetson AGX Orin",
|
||||
"Snapdragon X Elite",
|
||||
"Snapdragon X Plus",
|
||||
}
|
||||
assert set(GPU_SPECS.keys()) == expected
|
||||
|
||||
@@ -122,7 +127,6 @@ class TestGpuHardwareSpec:
|
||||
# Tests: energy integration math (trapezoidal rule)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnergyIntegration:
|
||||
def test_constant_power(self):
|
||||
"""Constant 300W for 10 seconds = 3000 J."""
|
||||
@@ -130,7 +134,6 @@ class TestEnergyIntegration:
|
||||
|
||||
snapshots = [[_snap(power=300)] for _ in range(11)]
|
||||
timestamps = [float(i) for i in range(11)]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=10.0)
|
||||
assert sample.energy_joules == pytest.approx(3000.0, rel=1e-6)
|
||||
assert sample.mean_power_watts == pytest.approx(300.0, rel=1e-6)
|
||||
@@ -146,7 +149,6 @@ class TestEnergyIntegration:
|
||||
[_snap(power=100.0 * i, util=50, mem=8, temp=60)] for i in range(5)
|
||||
]
|
||||
timestamps = [float(i) for i in range(5)]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=4.0)
|
||||
assert sample.energy_joules == pytest.approx(800.0, rel=1e-6)
|
||||
assert sample.mean_power_watts == pytest.approx(200.0, rel=1e-6)
|
||||
@@ -167,7 +169,6 @@ class TestEnergyIntegration:
|
||||
|
||||
snapshots = [[_snap(power=250, util=90, mem=16, temp=70)]]
|
||||
timestamps = [0.0]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=0.05)
|
||||
assert sample.energy_joules == 0.0
|
||||
assert sample.num_snapshots == 1
|
||||
@@ -178,7 +179,6 @@ class TestEnergyIntegration:
|
||||
# Tests: GpuSample aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGpuSampleAggregation:
|
||||
def test_peak_values(self):
|
||||
from openjarvis.telemetry.gpu_monitor import GpuMonitor
|
||||
@@ -189,7 +189,6 @@ class TestGpuSampleAggregation:
|
||||
[_snap(power=300, util=70, mem=15, temp=65)],
|
||||
]
|
||||
timestamps = [0.0, 1.0, 2.0]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0)
|
||||
assert sample.peak_power_watts == pytest.approx(400.0)
|
||||
assert sample.peak_utilization_pct == pytest.approx(95.0)
|
||||
@@ -205,7 +204,6 @@ class TestGpuSampleAggregation:
|
||||
[_snap(power=300, util=80, mem=16, temp=70)],
|
||||
]
|
||||
timestamps = [0.0, 1.0, 2.0]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0)
|
||||
assert sample.mean_power_watts == pytest.approx(200.0)
|
||||
assert sample.mean_utilization_pct == pytest.approx(60.0)
|
||||
@@ -217,7 +215,6 @@ class TestGpuSampleAggregation:
|
||||
# Tests: Multi-GPU aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiGpu:
|
||||
def test_multi_device_power_sum(self):
|
||||
"""Power summed across devices; util/temp averaged."""
|
||||
@@ -229,7 +226,6 @@ class TestMultiGpu:
|
||||
]
|
||||
snapshots = [tick, tick]
|
||||
timestamps = [0.0, 1.0]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=1.0)
|
||||
assert sample.energy_joules == pytest.approx(500.0)
|
||||
assert sample.mean_power_watts == pytest.approx(500.0)
|
||||
@@ -252,7 +248,6 @@ class TestMultiGpu:
|
||||
],
|
||||
]
|
||||
timestamps = [0.0, 2.0]
|
||||
|
||||
sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0)
|
||||
# Tick powers: 200, 600 => 0.5*(200+600)*2 = 800
|
||||
assert sample.energy_joules == pytest.approx(800.0)
|
||||
@@ -263,7 +258,6 @@ class TestMultiGpu:
|
||||
# Tests: context manager flow (with mocked pynvml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
def test_sample_context_manager(self):
|
||||
"""sample() starts/stops polling and populates result."""
|
||||
@@ -280,10 +274,8 @@ class TestContextManager:
|
||||
monitor._initialized = True
|
||||
monitor._device_count = 1
|
||||
monitor._handles = ["handle-0"]
|
||||
|
||||
with monitor.sample() as result:
|
||||
time.sleep(0.1)
|
||||
|
||||
assert result.duration_seconds > 0
|
||||
assert result.num_snapshots > 0
|
||||
assert result.mean_power_watts > 0
|
||||
@@ -301,10 +293,8 @@ class TestContextManager:
|
||||
monitor._handles = []
|
||||
monitor._device_count = 0
|
||||
monitor._initialized = False
|
||||
|
||||
with monitor.sample() as result:
|
||||
pass
|
||||
|
||||
assert result.num_snapshots == 0
|
||||
assert result.energy_joules == 0.0
|
||||
assert result.duration_seconds >= 0
|
||||
@@ -314,7 +304,6 @@ class TestContextManager:
|
||||
# Tests: available()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAvailable:
|
||||
def test_available_false_when_pynvml_missing(self):
|
||||
"""available() returns False when pynvml not importable."""
|
||||
@@ -330,7 +319,6 @@ class TestAvailable:
|
||||
def test_available_true_with_fake_pynvml(self):
|
||||
"""available() returns True when pynvml can init."""
|
||||
fake_pynvml = _make_fake_pynvml()
|
||||
|
||||
with patch.dict(sys.modules, {"pynvml": fake_pynvml}):
|
||||
import openjarvis.telemetry.gpu_monitor as mod
|
||||
|
||||
@@ -348,7 +336,6 @@ class TestAvailable:
|
||||
"""available() returns False when nvmlInit raises."""
|
||||
fake_pynvml = _make_fake_pynvml()
|
||||
fake_pynvml.nvmlInit.side_effect = RuntimeError("no driver")
|
||||
|
||||
with patch.dict(sys.modules, {"pynvml": fake_pynvml}):
|
||||
import openjarvis.telemetry.gpu_monitor as mod
|
||||
|
||||
@@ -365,7 +352,6 @@ class TestAvailable:
|
||||
# Tests: dataclass defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataclasses:
|
||||
def test_gpu_snapshot_defaults(self):
|
||||
from openjarvis.telemetry.gpu_monitor import GpuSnapshot
|
||||
|
||||
Reference in New Issue
Block a user