Merge pull request #156 from eddierichter-amd/lemonade-backend

Adding Lemonade Backend
This commit is contained in:
Robby Manihani
2026-04-02 01:53:10 -07:00
committed by GitHub
12 changed files with 223 additions and 25 deletions
+20 -4
View File
@@ -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?<br/>(A100, H100, H200,<br/>L40, A10, A30)"}
F -->|Yes| G["vllm"]
F -->|No| H["ollama"]
D -->|AMD| I["vllm"]
D -->|AMD| I{"Datacenter card?<br/>(MI300, MI325,<br/>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.
---
+11 -10
View File
@@ -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:
+9 -4
View File
@@ -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()
+9
View File
@@ -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"])
+22 -1
View File
@@ -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:
+1
View File
@@ -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,
@@ -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():
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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:
+5 -1
View File
@@ -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"
+110
View File
@@ -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"]
+33 -3
View File
@@ -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(