mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Merge pull request #156 from eddierichter-amd/lemonade-backend
Adding Lemonade Backend
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user