diff --git a/docs/architecture/engine.md b/docs/architecture/engine.md index 07fbe524..0be515d0 100644 --- a/docs/architecture/engine.md +++ b/docs/architecture/engine.md @@ -116,6 +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 | | **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 | @@ -132,7 +133,7 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and ### vLLM -The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (A100, H100, L40, A10, A30) and AMD GPUs. +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` - **Health check:** `GET /v1/models` @@ -199,6 +200,15 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA - **Install:** `pip install nexaai` - **Best for:** On-device inference with GGUF models on Apple Silicon or CPU +### Lemonade + +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` +- **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 + ### Uzu The Uzu backend connects to the Uzu inference runtime. Unlike other OpenAI-compatible engines, Uzu serves its API at the root path (no `/v1` prefix). @@ -254,7 +264,9 @@ graph TD D -->|NVIDIA| F{"Datacenter card?
(A100, H100, H200,
L40, A10, A30)"} F -->|Yes| G["vllm"] F -->|No| H["ollama"] - D -->|AMD| I["vllm"] + D -->|AMD| I{"Datacenter card?
(MI300, MI325,
MI350, MI355)"} + I -->|Yes| K["vllm"] + I -->|No| L["lemonade"] D -->|Other| J["llamacpp"] ``` @@ -301,7 +313,7 @@ models = discover_models(engines) ## OpenAI Compatibility Layer -The `_OpenAICompatibleEngine` base class provides a shared implementation for engines that serve the standard `/v1/chat/completions` endpoint. vLLM, SGLang, and llama.cpp all extend this base class with minimal overrides -- typically just setting `engine_id` and `_default_host`. +The `_OpenAICompatibleEngine` base class provides a shared implementation for engines that serve the standard `/v1/chat/completions` endpoint. vLLM, SGLang, llama.cpp, Lemonade, and others all extend this base class with minimal overrides -- typically just setting `engine_id` and `_default_host`. ```python class _OpenAICompatibleEngine(InferenceEngine): @@ -343,6 +355,9 @@ host = "http://localhost:30000" # [engine.llamacpp] # host = "http://localhost:8080" # binary_path = "" + +# [engine.lemonade] +# host = "http://localhost:8000" ``` The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings: @@ -355,9 +370,10 @@ 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 | !!! note "Backward compatibility" - The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, and `sglang_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format. + 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. --- diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py index c6f853c6..1b8eb8c0 100644 --- a/src/openjarvis/agents/executor.py +++ b/src/openjarvis/agents/executor.py @@ -312,12 +312,16 @@ class AgentExecutor: ) # Construct agent instance - agent_instance = agent_cls( - engine, - model, - system_prompt=config.get("system_prompt"), - tools=tool_instances, - ) + agent_kwargs: dict[str, Any] = {} + sys_prompt = config.get("system_prompt") + if sys_prompt is not None: + agent_kwargs["system_prompt"] = sys_prompt + if getattr(agent_cls, "accepts_tools", False) and tool_instances: + agent_kwargs["tools"] = tool_instances + try: + agent_instance = agent_cls(engine, model, **agent_kwargs) + except TypeError: + agent_instance = agent_cls(engine, model) # Build input from instruction + summary_memory + pending messages import datetime @@ -326,10 +330,7 @@ class AgentExecutor: instruction = config.get("instruction", "") memory = agent.get("summary_memory", "") if instruction: - input_text = ( - f"Current date: {today}\n\n" - f"Standing instruction: {instruction}" - ) + input_text = f"Current date: {today}\n\nStanding instruction: {instruction}" if memory: input_text += f"\n\nPrevious context: {memory}" else: diff --git a/src/openjarvis/cli/compose_cmd.py b/src/openjarvis/cli/compose_cmd.py index c3509c5a..e7af878c 100644 --- a/src/openjarvis/cli/compose_cmd.py +++ b/src/openjarvis/cli/compose_cmd.py @@ -197,8 +197,6 @@ def compose_run(name: str, query: tuple[str, ...], output_json: bool) -> None: agent_kwargs = {} if kwargs.get("system_prompt"): agent_kwargs["system_prompt"] = kwargs["system_prompt"] - if kwargs.get("max_turns"): - agent_kwargs["max_turns"] = kwargs["max_turns"] if kwargs.get("temperature"): agent_kwargs["temperature"] = kwargs["temperature"] @@ -207,7 +205,9 @@ def compose_run(name: str, query: tuple[str, ...], output_json: bool) -> None: if output_json: import json as json_mod - if isinstance(result, str): + if isinstance(result, dict): + click.echo(json_mod.dumps(result, indent=2, default=str)) + elif isinstance(result, str): click.echo(json_mod.dumps({"content": result}, indent=2)) else: click.echo( @@ -220,7 +220,12 @@ def compose_run(name: str, query: tuple[str, ...], output_json: bool) -> None: ) ) else: - content = result if isinstance(result, str) else result.content + if isinstance(result, dict): + content = result.get("content", "") + elif isinstance(result, str): + content = result + else: + content = result.content click.echo(content) finally: system.close() diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index 98b9da85..c75a2206 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -160,6 +160,15 @@ def _next_steps_text(engine: str, model: str = "") -> str: ' jarvis ask "Hello"\n\n' " Run `jarvis doctor` to verify your setup." ), + "lemonade": ( + "Next steps:\n\n" + " 1. Install Lemonade for your platform:\n" + " https://lemonade-server.ai/\n\n" + " 2. Start the Lemonade server\n\n" + " 3. Try it out:\n" + ' jarvis ask "Hello"\n\n' + " Run `jarvis doctor` to verify your setup." + ), } return steps.get(engine, steps["ollama"]) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index dea8ab2c..86dca2f0 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -220,7 +220,11 @@ def recommend_engine(hw: HardwareInfo) -> str: return "vllm" return "ollama" if gpu.vendor == "amd": - return "vllm" + # Datacenter cards (MI300, MI325, MI350, MI355) → vllm; consumer → lemonade + amd_datacenter_keywords = ("MI300", "MI325", "MI350", "MI355") + if any(kw in gpu.name for kw in amd_datacenter_keywords): + return "vllm" + return "lemonade" return "llamacpp" @@ -354,6 +358,13 @@ class GemmaCppEngineConfig: num_threads: int = 0 +@dataclass(slots=True) +class LemonadeEngineConfig: + """Per-engine config for Lemonade.""" + + host: str = "http://localhost:8000" + + @dataclass class EngineConfig: """Inference engine settings with nested per-engine configs.""" @@ -370,6 +381,7 @@ class EngineConfig: uzu: UzuEngineConfig = field(default_factory=UzuEngineConfig) apple_fm: AppleFmEngineConfig = field(default_factory=AppleFmEngineConfig) gemma_cpp: GemmaCppEngineConfig = field(default_factory=GemmaCppEngineConfig) + lemonade: LemonadeEngineConfig = field(default_factory=LemonadeEngineConfig) # Backward-compat properties for old flat attribute names @property @@ -471,6 +483,15 @@ class EngineConfig: def apple_fm_host(self, value: str) -> None: self.apple_fm.host = value + @property + def lemonade_host(self) -> str: + """Deprecated: use ``engine.lemonade.host``.""" + return self.lemonade.host + + @lemonade_host.setter + def lemonade_host(self, value: str) -> None: + self.lemonade.host = value + @dataclass(slots=True) class IntelligenceConfig: diff --git a/src/openjarvis/engine/_discovery.py b/src/openjarvis/engine/_discovery.py index 83021858..fa926704 100644 --- a/src/openjarvis/engine/_discovery.py +++ b/src/openjarvis/engine/_discovery.py @@ -23,6 +23,7 @@ _HOST_MAP: Dict[str, str | None] = { "nexa": "nexa_host", "uzu": "uzu_host", "apple_fm": "apple_fm_host", + "lemonade": "lemonade_host", "cloud": None, "litellm": None, "gemma_cpp": None, diff --git a/src/openjarvis/engine/openai_compat_engines.py b/src/openjarvis/engine/openai_compat_engines.py index 904c115e..95ec1200 100644 --- a/src/openjarvis/engine/openai_compat_engines.py +++ b/src/openjarvis/engine/openai_compat_engines.py @@ -13,6 +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"), } for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items(): diff --git a/src/openjarvis/intelligence/model_catalog.py b/src/openjarvis/intelligence/model_catalog.py index 9aed067a..c98dd781 100644 --- a/src/openjarvis/intelligence/model_catalog.py +++ b/src/openjarvis/intelligence/model_catalog.py @@ -16,7 +16,7 @@ BUILTIN_MODELS: List[ModelSpec] = [ name="Qwen3 8B", parameter_count_b=8.2, context_length=32768, - supported_engines=("vllm", "ollama", "llamacpp", "sglang"), + supported_engines=("vllm", "ollama", "llamacpp", "sglang", "lemonade"), provider="alibaba", metadata={ "architecture": "dense", diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 3c179e88..338ea025 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -74,7 +74,7 @@ class TestRecommendEngine: platform="linux", gpu=GpuInfo(vendor="amd", name="Radeon RX 7900 XTX"), ) - assert recommend_engine(hw) == "vllm" + assert recommend_engine(hw) == "lemonade" class TestTomlLoading: diff --git a/tests/engine/test_engine_model_matrix.py b/tests/engine/test_engine_model_matrix.py index a8ad1554..9547ff0b 100644 --- a/tests/engine/test_engine_model_matrix.py +++ b/tests/engine/test_engine_model_matrix.py @@ -12,6 +12,7 @@ from openjarvis.engine.ollama import OllamaEngine from openjarvis.engine.openai_compat_engines import ( AppleFmEngine, ExoEngine, + LemonadeEngine, LlamaCppEngine, LMStudioEngine, MLXEngine, @@ -31,13 +32,16 @@ _OPENAI_COMPAT_ENGINES = [ ("nexa", "http://testhost:18181", NexaEngine), ("uzu", "http://testhost:8000", UzuEngine), ("apple_fm", "http://testhost:8079", AppleFmEngine), + ("lemonade", "http://testhost:8000", LemonadeEngine), ] def _api_prefix(engine_key: str) -> str: - """Return the API prefix for an engine (Uzu uses no prefix).""" + """Return the API prefix for an engine.""" if engine_key == "uzu": return "" + if engine_key == "lemonade": + return "/v1" return "/v1" diff --git a/tests/engine/test_lemonade.py b/tests/engine/test_lemonade.py new file mode 100644 index 00000000..d5bc0e37 --- /dev/null +++ b/tests/engine/test_lemonade.py @@ -0,0 +1,110 @@ +"""Tests for the Lemonade engine backend. + +Validates that the ``/v1`` prefix is used correctly for models listing +and chat completions. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from openjarvis.core.registry import EngineRegistry +from openjarvis.core.types import Message, Role +from openjarvis.engine._base import EngineConnectionError +from openjarvis.engine.openai_compat_engines import LemonadeEngine + + +@pytest.fixture() +def engine() -> LemonadeEngine: + EngineRegistry.register_value("lemonade", LemonadeEngine) + return LemonadeEngine(host="http://testhost:8000") + + +class TestLemonadeEngineBasics: + def test_engine_id(self) -> None: + assert LemonadeEngine.engine_id == "lemonade" + + def test_default_host(self) -> None: + assert LemonadeEngine._default_host == "http://localhost:8000" + + def test_api_prefix(self) -> None: + assert LemonadeEngine._api_prefix == "/v1" + + def test_registry_registration(self) -> None: + EngineRegistry.register_value("lemonade", LemonadeEngine) + assert EngineRegistry.get("lemonade") is LemonadeEngine + + +class TestLemonadeGenerate: + def test_generate_uses_v1_prefix(self, engine: LemonadeEngine) -> None: + with respx.mock: + respx.post("http://testhost:8000/v1/chat/completions").mock( + return_value=httpx.Response( + 200, + json={ + "choices": [ + { + "message": {"content": "Hello!"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 2, + "total_tokens": 7, + }, + "model": "Qwen3-4B-Instruct", + }, + ) + ) + result = engine.generate( + [Message(role=Role.USER, content="Hi")], model="Qwen3-4B-Instruct" + ) + assert result["content"] == "Hello!" + assert result["usage"]["total_tokens"] == 7 + + def test_generate_connection_error(self, engine: LemonadeEngine) -> None: + with respx.mock: + respx.post("http://testhost:8000/v1/chat/completions").mock( + side_effect=httpx.ConnectError("refused") + ) + with pytest.raises(EngineConnectionError): + engine.generate( + [Message(role=Role.USER, content="Hi")], + model="Qwen3-4B-Instruct", + ) + + +class TestLemonadeHealth: + def test_health_true(self, engine: LemonadeEngine) -> None: + with respx.mock: + respx.get("http://testhost:8000/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( + side_effect=httpx.ConnectError("refused") + ) + assert engine.health() is False + + +class TestLemonadeListModels: + def test_list_models_uses_v1_prefix(self, engine: LemonadeEngine) -> None: + with respx.mock: + respx.get("http://testhost:8000/v1/models").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + {"id": "Qwen3-4B-Instruct"}, + {"id": "Llama-3.1-8B"}, + ] + }, + ) + ) + assert engine.list_models() == ["Qwen3-4B-Instruct", "Llama-3.1-8B"] diff --git a/tests/hardware/test_amd.py b/tests/hardware/test_amd.py index 3eecdae8..9b880918 100644 --- a/tests/hardware/test_amd.py +++ b/tests/hardware/test_amd.py @@ -131,7 +131,7 @@ class TestAMDDetection: class TestAMDEngineRecommendation: - """Tests that AMD cards map to vllm.""" + """Tests that AMD datacenter cards map to vllm, consumer cards to lemonade.""" def test_mi300x_recommends_vllm(self): hw = HardwareInfo( @@ -148,7 +148,37 @@ class TestAMDEngineRecommendation: ) assert recommend_engine(hw) == "vllm" - def test_amd_generic_recommends_vllm(self): + def test_mi350_recommends_vllm(self): + hw = HardwareInfo( + platform="linux", + cpu_brand="AMD EPYC 9654", + cpu_count=96, + ram_gb=768.0, + gpu=GpuInfo( + vendor="amd", + name="AMD Instinct MI350X", + vram_gb=288.0, + count=1, + ), + ) + assert recommend_engine(hw) == "vllm" + + def test_amd_consumer_recommends_lemonade(self): + hw = HardwareInfo( + platform="linux", + cpu_brand="AMD Ryzen 9 7950X", + cpu_count=32, + ram_gb=64.0, + gpu=GpuInfo( + vendor="amd", + name="AMD Radeon RX 7900 XTX", + vram_gb=24.0, + count=1, + ), + ) + assert recommend_engine(hw) == "lemonade" + + def test_amd_generic_recommends_lemonade(self): hw = HardwareInfo( platform="linux", cpu_brand="AMD EPYC", @@ -156,7 +186,7 @@ class TestAMDEngineRecommendation: ram_gb=256.0, gpu=GpuInfo(vendor="amd", name="AMD GPU", vram_gb=0.0, count=1), ) - assert recommend_engine(hw) == "vllm" + assert recommend_engine(hw) == "lemonade" def test_amd_multi_gpu_recommends_vllm(self): hw = HardwareInfo(