Merge pull request #14840 from eddierichter-amd/lemonade-integration

Add AMD Lemonade provider support
This commit is contained in:
Krish Dholakia
2025-09-30 12:34:30 -07:00
committed by GitHub
14 changed files with 635 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Lemonade
[Lemonade Server](https://lemonade-server.ai/) is an OpenAI-compatible local language model inference provider optimized for AMD GPUs and NPUs. The `lemonade` litellm provider supports standard chat completions with full OpenAI API compatibility.
| Property | Details |
|-------|-------|
| Description | OpenAI-compatible AI provider for local and cloud-based language model inference |
| Provider Route on LiteLLM | `lemonade/` (add this prefix to the model name - e.g. `lemonade/your-model-name`) |
| API Endpoint for Provider | http://localhost:8000/api/v1 (default) |
| Supported Endpoints | `/chat/completions` |
## Supported OpenAI Parameters
Lemonade is fully OpenAI-compatible and supports the following parameters:
```
"repeat_penalty"
"functions"
"logit_bias"
"max_tokens"
"max_completion_tokens"
"presence_penalty"
"stop"
"temperature"
"top_p"
"top_k"
"response_format"
"tools"
```
## API Key Setup
Lemonade can be configured with custom API URLs and doesn't require strict API key validation. Set the `LEMONADE_API_BASE` environment variable to modify the base URL.
## Usage
<Tabs>
<TabItem value="sdk" label="SDK">
```python
from litellm import completion
import os
# Optional: Set custom API base. Useful if your lemonade server is on
# a different port
os.environ['LEMONADE_API_BASE'] = "http://localhost:8000/api/v1"
response = completion(
model="lemonade/your-model-name",
messages=[
{"role": "user", "content": "Hello from LiteLLM!"}
],
)
print(response)
```
## Streaming
```python
from litellm import completion
import os
# Optional: Set custom API base. Useful if your lemonade server is on
# a different port
os.environ['LEMONADE_API_BASE'] = "http://localhost:8000/api/v1"
response = completion(
model="lemonade/your-model-name",
messages=[
{"role": "user", "content": "Write a short story"}
],
stream=True
)
for chunk in response:
print(chunk.choices[0].delta.content, end='', flush=True)
```
## Advanced Usage
### Custom Parameters
Lemonade supports additional parameters beyond the standard OpenAI set:
```python
from litellm import completion
response = completion(
model="lemonade/your-model-name",
messages=[{"role": "user", "content": "Explain quantum computing"}],
temperature=0.7,
max_tokens=500,
top_p=0.9,
top_k=50,
repeat_penalty=1.1,
stop=["Human:", "AI:"]
)
print(response)
```
### Function Calling
Lemonade supports OpenAI-compatible function calling:
```python
from litellm import completion
functions = [
{
"name": "get_weather",
"description": "Get current weather information",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
]
response = completion(
model="lemonade/your-model-name",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
tools=[{"type": "function", "function": f} for f in functions],
tool_choice="auto"
)
print(response)
```
### Response Format
Lemonade supports structured output with response format:
```python
from litellm import completion
import json
# Define schema in response_format
response = completion(
model="lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF",
messages=[{"role": "user", "content": "Generate JSON data for a person with their name, age, and city."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"}
},
"required": ["name", "age"]
}
}
}
)
print(f"Model: {response.model}")
print(f"JSON Output:")
json_data = json.loads(response.choices[0].message.content)
print(json.dumps(json_data, indent=2))
```
## Available Models
Lemonade automatically validates available models by querying the `/models` endpoint. You can check available models programmatically:
```python
import httpx
api_base = "http://localhost:8000" # or your custom base
response = httpx.get(f"{api_base}/api/v1/models")
models = response.json()
print("Available models:", [model['id'] for model in models.get('data', [])])
```
## Support
For more information regarding Lemonade please go to to the [Lemonade website](https://lemonade-server.ai/) or [Lemonade repository](https://github.com/lemonade-sdk/lemonade).
+1
View File
@@ -477,6 +477,7 @@ const sidebars = {
"providers/fireworks_ai",
"providers/clarifai",
"providers/compactifai",
"providers/lemonade",
"providers/vllm",
"providers/llamafile",
"providers/infinity",
+7
View File
@@ -250,6 +250,7 @@ wandb_key: Optional[str] = None
heroku_key: Optional[str] = None
cometapi_key: Optional[str] = None
ovhcloud_key: Optional[str] = None
lemonade_key: Optional[str] = None
common_cloud_provider_auth_params: dict = {
"params": ["project", "region_name", "token"],
"providers": ["vertex_ai", "bedrock", "watsonx", "azure", "vertex_ai_beta"],
@@ -536,6 +537,7 @@ volcengine_models: Set = set()
wandb_models: Set = set(WANDB_MODELS)
ovhcloud_models: Set = set()
ovhcloud_embedding_models: Set = set()
lemonade_models: Set = set()
def is_bedrock_pricing_only_model(key: str) -> bool:
@@ -756,6 +758,8 @@ def add_known_models():
ovhcloud_models.add(key)
elif value.get("litellm_provider") == "ovhcloud-embedding-models":
ovhcloud_embedding_models.add(key)
elif value.get("litellm_provider") == "lemonade":
lemonade_models.add(key)
add_known_models()
@@ -852,6 +856,7 @@ model_list = list(
| volcengine_models
| wandb_models
| ovhcloud_models
| lemonade_models
)
model_list_set = set(model_list)
@@ -935,6 +940,7 @@ models_by_provider: dict = {
"volcengine": volcengine_models,
"wandb": wandb_models,
"ovhcloud": ovhcloud_models | ovhcloud_embedding_models,
"lemonade": lemonade_models,
}
# mapping for those models which have larger equivalents
@@ -1284,6 +1290,7 @@ from .llms.hyperbolic.chat.transformation import HyperbolicChatConfig
from .llms.vercel_ai_gateway.chat.transformation import VercelAIGatewayConfig
from .llms.ovhcloud.chat.transformation import OVHCloudChatConfig
from .llms.ovhcloud.embedding.transformation import OVHCloudEmbeddingConfig
from .llms.lemonade.chat.transformation import LemonadeChatConfig
from .main import * # type: ignore
from .integrations import *
from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients
+1
View File
@@ -315,6 +315,7 @@ LITELLM_CHAT_PROVIDERS = [
"vercel_ai_gateway",
"wandb",
"ovhcloud",
"lemonade"
]
LITELLM_EMBEDDING_PROVIDERS_SUPPORTING_INPUT_ARRAY_OF_TOKENS = [
+5
View File
@@ -58,6 +58,9 @@ from litellm.llms.vertex_ai.cost_calculator import (
)
from litellm.llms.vertex_ai.cost_calculator import cost_router as google_cost_router
from litellm.llms.xai.cost_calculator import cost_per_token as xai_cost_per_token
from litellm.llms.lemonade.cost_calculator import (
cost_per_token as lemonade_cost_per_token,
)
from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.llms.openai import (
HttpxBinaryResponseContent,
@@ -347,6 +350,8 @@ def cost_per_token( # noqa: PLR0915
return perplexity_cost_per_token(model=model, usage=usage_block)
elif custom_llm_provider == "xai":
return xai_cost_per_token(model=model, usage=usage_block)
elif custom_llm_provider == "lemonade":
return lemonade_cost_per_token(model=model, usage=usage_block)
elif custom_llm_provider == "dashscope":
from litellm.llms.dashscope.cost_calculator import (
cost_per_token as dashscope_cost_per_token,
@@ -368,6 +368,8 @@ def get_llm_provider( # noqa: PLR0915
# bytez models
elif model.startswith("bytez/"):
custom_llm_provider = "bytez"
elif model.startswith("lemonade/"):
custom_llm_provider = "lemonade"
elif model.startswith("heroku/"):
custom_llm_provider = "heroku"
# cometapi models
@@ -379,6 +381,8 @@ def get_llm_provider( # noqa: PLR0915
custom_llm_provider = "compactifai"
elif model.startswith("ovhcloud/"):
custom_llm_provider = "ovhcloud"
elif model.startswith("lemonade/"):
custom_llm_provider = "lemonade"
if not custom_llm_provider:
if litellm.suppress_debug_info is False:
print() # noqa
@@ -783,6 +787,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
or "https://api.inference.wandb.ai/v1"
) # type: ignore
dynamic_api_key = api_key or get_secret_str("WANDB_API_KEY")
elif custom_llm_provider == "lemonade":
(
api_base,
dynamic_api_key,
) = litellm.LemonadeChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
if api_base is not None and not isinstance(api_base, str):
raise Exception("api base needs to be a string. api_base={}".format(api_base))
@@ -0,0 +1,149 @@
"""
Translate from OpenAI's `/v1/chat/completions` to Lemonade's `/v1/chat/completions`
"""
from typing import Any, List, Optional, Tuple, Union
import httpx
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
AllMessageValues,
)
from litellm.types.utils import ModelResponse
from ...openai_like.chat.transformation import OpenAILikeChatConfig
class LemonadeChatConfig(OpenAILikeChatConfig):
repeat_penalty: Optional[float] = None
functions: Optional[list] = None
logit_bias: Optional[dict] = None
max_tokens: Optional[int] = None
max_completion_tokens: Optional[int] = None
n: Optional[int] = None
presence_penalty: Optional[int] = None
stop: Optional[Union[str, list]] = None
temperature: Optional[int] = None
top_p: Optional[int] = None
top_k: Optional[int] = None
response_format: Optional[dict] = None
tools: Optional[list] = None
def __init__(
self,
repeat_penalty: Optional[float] = None,
functions: Optional[list] = None,
logit_bias: Optional[dict] = None,
max_completion_tokens: Optional[int] = None,
max_tokens: Optional[int] = None,
n: Optional[int] = None,
presence_penalty: Optional[int] = None,
stop: Optional[Union[str, list]] = None,
temperature: Optional[int] = None,
top_p: Optional[int] = None,
top_k: Optional[int] = None,
response_format: Optional[dict] = None,
tools: Optional[list] = None,
) -> None:
locals_ = locals().copy()
for key, value in locals_.items():
if key != "self" and value is not None:
setattr(self.__class__, key, value)
@property
def custom_llm_provider(self) -> Optional[str]:
return "lemonade"
@classmethod
def get_config(cls):
return super().get_config()
def get_models(self, api_key: Optional[str] = None, api_base: Optional[str] = None):
"""
Get available models from Lemonade API.
This method queries the Lemonade /models endpoint to retrieve the list of available models.
Args:
api_key: Optional API key (Lemonade doesn't require authentication)
api_base: Optional API base URL (defaults to LEMONADE_API_BASE env var or http://localhost:8000)
Returns:
List of model names prefixed with "lemonade/"
"""
api_base, api_key = self._get_openai_compatible_provider_info(
api_base=api_base, api_key=api_key
)
if api_base is None:
raise ValueError(
"LEMONADE_API_BASE is not set. Please set the environment variable to query Lemonade's /models endpoint."
)
# Getting the list of models from lemonade
try:
response = litellm.module_level_client.get(
url=f"{api_base}/models",
)
except Exception as e:
raise ValueError(
f"Failed to fetch models from Lemonade. Set Lemonade API Base via `LEMONADE_API_BASE` environment variable. Error: {e}"
)
if response.status_code != 200:
raise ValueError(
f"Failed to fetch models from Lemonade. Status code: {response.status_code}, Response: {response.text}"
)
model_list = response.json().get("data", [])
return ["lemonade/" + model["id"] for model in model_list]
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
# lemonade is openai compatible, we just need to set this to custom_openai and have the api_base be lemonade's endpoint
api_base = (
api_base
or get_secret_str("LEMONADE_API_BASE")
or "http://localhost:8000/api/v1"
) # type: ignore
# Lemonade doesn't check the key
key = "lemonade"
return api_base, key
def transform_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ModelResponse,
logging_obj: LiteLLMLoggingObj,
request_data: dict,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
encoding: Any,
api_key: Optional[str] = None,
json_mode: Optional[bool] = None,
) -> ModelResponse:
model_response = super().transform_response(
model=model,
model_response=model_response,
raw_response=raw_response,
messages=messages,
logging_obj=logging_obj,
request_data=request_data,
encoding=encoding,
optional_params=optional_params,
json_mode=json_mode,
litellm_params=litellm_params,
api_key=api_key,
)
# Storing lemonade in the model response for easier cost calculation later
setattr(model_response, "model", "lemonade/" + model)
return model_response
+35
View File
@@ -0,0 +1,35 @@
"""
Cost calculation for Lemonade LLM provider.
Since Lemonade is a local/self-hosted service, all costs default to 0.
This prevents cost calculation errors when using models not in model_prices_and_context_window.json
"""
from typing import Tuple
from litellm.types.utils import Usage
def cost_per_token(
model: str,
usage: Usage,
) -> Tuple[float, float]:
"""
Calculate cost per token for Lemonade models.
Since Lemonade is a local/self-hosted deployment, there are no per-token costs.
This function returns (0.0, 0.0) for all models to allow cost tracking to work
without errors for any Lemonade model, regardless of whether it's in the
model_prices_and_context_window.json file.
Args:
model: The model name (with or without "lemonade/" prefix)
usage: Usage object containing token counts
Returns:
Tuple of (prompt_cost, completion_cost) - always (0.0, 0.0) for Lemonade
"""
# Lemonade is self-hosted/local, so cost is always 0
prompt_cost = 0.0
completion_cost = 0.0
return prompt_cost, completion_cost
+31
View File
@@ -149,6 +149,7 @@ from .llms.bedrock.chat import BedrockConverseLLM, BedrockLLM
from .llms.bedrock.embed.embedding import BedrockEmbedding
from .llms.bedrock.image.image_handler import BedrockImageGeneration
from .llms.bytez.chat.transformation import BytezChatConfig
from .llms.lemonade.chat.transformation import LemonadeChatConfig
from .llms.codestral.completion.handler import CodestralTextCompletion
from .llms.cohere.embed import handler as cohere_embed
from .llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler
@@ -267,6 +268,7 @@ bytez_transformation = BytezChatConfig()
heroku_transformation = HerokuChatConfig()
oci_transformation = OCIChatConfig()
ovhcloud_transformation = OVHCloudChatConfig()
lemonade_transformation = LemonadeChatConfig()
####### COMPLETION ENDPOINTS ################
@@ -3545,6 +3547,35 @@ def completion( # type: ignore # noqa: PLR0915
)
pass
elif custom_llm_provider == "lemonade":
api_key = (
api_key
or litellm.lemonade_key
or get_secret_str("LEMONADE_API_KEY")
or litellm.api_key
)
response = base_llm_http_handler.completion(
model=model,
messages=messages,
headers=headers,
model_response=model_response,
api_key=api_key,
api_base=api_base,
acompletion=acompletion,
logging_obj=logging,
optional_params=optional_params,
litellm_params=litellm_params,
timeout=timeout, # type: ignore
client=client,
custom_llm_provider=custom_llm_provider,
encoding=encoding,
stream=stream,
provider_config=lemonade_transformation,
)
pass
elif custom_llm_provider == "ovhcloud" or model in litellm.ovhcloud_models:
api_key = (
@@ -13256,6 +13256,18 @@
],
"supports_tool_choice": false
},
"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF": {
"input_cost_per_token": 0,
"litellm_provider": "lemonade",
"max_tokens": 32768,
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 0,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"groq/deepseek-r1-distill-llama-70b": {
"input_cost_per_token": 7.5e-07,
"litellm_provider": "groq",
+1
View File
@@ -2428,6 +2428,7 @@ class LlmProviders(str, Enum):
DOTPROMPT = "dotprompt"
WANDB = "wandb"
OVHCLOUD = "ovhcloud"
LEMONADE = "lemonade"
# Create a set of all provider values for quick lookup
+2
View File
@@ -7317,6 +7317,8 @@ class ProviderConfigManager:
)
return VLLMModelInfo()
elif LlmProviders.LEMONADE == provider:
return litellm.LemonadeChatConfig()
return None
@staticmethod
+12
View File
@@ -13256,6 +13256,18 @@
],
"supports_tool_choice": false
},
"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF": {
"input_cost_per_token": 0,
"litellm_provider": "lemonade",
"max_tokens": 32768,
"max_input_tokens": 32768,
"max_output_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 0,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"groq/deepseek-r1-distill-llama-70b": {
"input_cost_per_token": 7.5e-07,
"litellm_provider": "groq",
@@ -0,0 +1,180 @@
import json
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from unittest.mock import MagicMock, patch
from litellm.llms.lemonade.chat.transformation import LemonadeChatConfig
from litellm.types.utils import ModelResponse
import httpx
def test_lemonade_config_initialization():
"""Test that LemonadeChatConfig can be initialized with various parameters"""
config = LemonadeChatConfig(
temperature=0.7,
max_tokens=100,
top_p=0.9,
top_k=50,
repeat_penalty=1.1
)
assert config.custom_llm_provider == "lemonade"
assert config.temperature == 0.7
assert config.max_tokens == 100
assert config.top_p == 0.9
assert config.top_k == 50
assert config.repeat_penalty == 1.1
def test_get_openai_compatible_provider_info():
"""Test the provider info method returns correct API base and key"""
config = LemonadeChatConfig()
api_base, key = config._get_openai_compatible_provider_info(
api_base=None,
api_key=None
)
assert api_base == "http://localhost:8000/api/v1"
assert key == "lemonade"
def test_get_openai_compatible_provider_info_with_custom_base():
"""Test the provider info method with custom API base"""
config = LemonadeChatConfig()
custom_api_base = "https://custom.lemonade.ai/v1"
api_base, key = config._get_openai_compatible_provider_info(
api_base=custom_api_base,
api_key=None
)
assert api_base == custom_api_base
assert key == "lemonade"
def test_transform_response():
"""Test the response transformation adds lemonade prefix to model name"""
config = LemonadeChatConfig()
# Mock raw response
raw_response = MagicMock()
raw_response.status_code = 200
raw_response.headers = {}
# Create a model response
model_response = ModelResponse()
# Mock the parent class transform_response method
with patch.object(config.__class__.__bases__[0], 'transform_response') as mock_parent:
mock_parent.return_value = model_response
result = config.transform_response(
model="test-model",
raw_response=raw_response,
model_response=model_response,
logging_obj=MagicMock(),
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
api_key="test-key",
json_mode=False,
)
# Check that the model name is prefixed with "lemonade/"
assert hasattr(result, 'model')
assert result.model == "lemonade/test-model"
def test_config_get_config():
"""Test that get_config method returns the configuration"""
config_dict = LemonadeChatConfig.get_config()
assert isinstance(config_dict, dict)
def test_response_format_support():
"""Test that response_format parameter is supported"""
response_format = {
"type": "json_object"
}
config = LemonadeChatConfig(response_format=response_format)
assert config.response_format == response_format
def test_tools_support():
"""Test that tools parameter is supported"""
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather information"
}
}
]
config = LemonadeChatConfig(tools=tools)
assert config.tools == tools
def test_functions_support():
"""Test that functions parameter is supported"""
functions = [
{
"name": "get_weather",
"description": "Get weather information",
"parameters": {
"type": "object",
"properties": {}
}
}
]
config = LemonadeChatConfig(functions=functions)
assert config.functions == functions
def test_stop_parameter_support():
"""Test that stop parameter supports both string and list"""
# Test with string
config1 = LemonadeChatConfig(stop="STOP")
assert config1.stop == "STOP"
# Test with list
config2 = LemonadeChatConfig(stop=["STOP", "END"])
assert config2.stop == ["STOP", "END"]
def test_logit_bias_support():
"""Test that logit_bias parameter is supported"""
logit_bias = {"50256": -100}
config = LemonadeChatConfig(logit_bias=logit_bias)
assert config.logit_bias == logit_bias
def test_presence_penalty_support():
"""Test that presence_penalty parameter is supported"""
config = LemonadeChatConfig(presence_penalty=0.5)
assert config.presence_penalty == 0.5
def test_n_parameter_support():
"""Test that n parameter (number of completions) is supported"""
config = LemonadeChatConfig(n=3)
assert config.n == 3
def test_max_completion_tokens_support():
"""Test that max_completion_tokens parameter is supported"""
config = LemonadeChatConfig(max_completion_tokens=150)
assert config.max_completion_tokens == 150