Pi extension that registers your local
Lemonade Server as a provider and discovers its
downloaded models automatically. Add a model to the server, and
it is available in /model — no manual models.json updates required.
Works with pi 0.83 and later (verified through 0.87) — the extension adapts at runtime to each pi version's provider API, so no per-version setup is needed. On a pi release that the extension has not been verified against, streaming should still work — but if it misbehaves (missing system prompt or tools, unexpected request errors), you can switch to pi's built-in OpenAI-compatible streaming, which is always generation-matched to your pi (see Choosing the streaming mode).
-
Prepare your models: Ensure your Lemonade Server is running and you have downloaded at least one model.
- In Web UI / Desktop App: Use the "Downloaded Only" checkbox to quickly see which models are ready.
- In CLI: Run
lemonade list --downloaded.
-
Install the extension: Install into Pi via the CLI:
pi install npm:pi-provider-lemonade
-
Select a model: Inside the Pi TUI, run:
/model(or use the
ctrl-lkeybinding). Pick anylemonade/...entry from the list. -
Refresh the catalog: If you download new models, update the list without restarting:
/reload(or
pi update --modelsin the CLI).
The extension's own settings live in pi-provider-lemonade.json in the
agent directory (PI_CODING_AGENT_DIR, or ~/.pi/agent by default — the
same directory as auth.json and models.json):
{
"discoveryTimeoutMs": 20000,
"streamingMode": "extension",
"startupScan": true
}All keys are optional; a missing file means all defaults.
| Key | Default | Purpose |
|---|---|---|
discoveryTimeoutMs |
20000 |
Overall budget for one model-discovery pass, covering /v1/models, /v1/health and every /api/show together (individual requests are still capped at 4 seconds). Raise it only for very large model catalogs |
streamingMode |
"extension" |
"extension" — this extension's streaming layer (Lemonade error reporting and retry tuning); "builtin" — pi's built-in OpenAI-compatible streaming (see Choosing the streaming mode) |
startupScan |
platform: true on Linux, false elsewhere |
Run the background network scan at pi startup (it pre-populates the /login server list). Off by default on macOS/Windows because the scan's inbound UDP makes the OS firewall prompt about the node process. The on-demand Scan local network… in /login is never affected |
The file takes precedence over the deprecated sources (the
LEMONADE_DISCOVERY_TIMEOUT_MS env var and the models.json
x-pi-provider-lemonade header), is re-read on every /reload, and a
malformed file never blocks startup — it is reported once and the defaults
apply.
The extension connects to your local Lemonade server using the following environment variables:
| Variable | Default | Purpose |
|---|---|---|
LEMONADE_HOST |
127.0.0.1 |
Server host — a bare host, host:port, or a full http:///https:// URL (parsed exactly like the Lemonade SDK CLI's LEMONADE_HOST) |
LEMONADE_PORT |
13305 |
Server port — used unless LEMONADE_HOST already carries an explicit port |
LEMONADE_DISCOVERY_TIMEOUT_MS (deprecated) |
20000 |
Deprecated — use discoveryTimeoutMs in the configuration file instead. Still honored when the file does not set the key; a startup notice points at the replacement |
LEMONADE_HOST accepts the same forms the lemonade CLI does: a bare
host (127.0.0.1), host:port (192.168.1.100:13305), or a full URL
(http://192.168.1.100:13305, https://llm.example.com). Trailing
path/query/fragment is stripped, and the scheme selects http or https —
so LEMONADE_HOST=https://llm.example.com connects over TLS. IPv6 hosts
are supported (::1 or [::1]:13305).
An explicit port in
LEMONADE_HOSTwins overLEMONADE_PORT; a scheme without a port (http://host) falls back to 80/443 unlessLEMONADE_PORTis set. A port that does not parse or is out of range is never used — the extension falls back toLEMONADE_PORT(or13305) and says so in the startup notice.
You can configure the server in two ways:
-
Pi Credential (Preferred): Run
/login lemonadeinside the Pi TUI. It shows a server list — the URL from a previous login (when one is stored), theLEMONADE_HOST/LEMONADE_PORTbase, and any servers found by the network scan (below) — each annotated with a liveonline/offlinestatus — plus Scan local network… and Enter custom URL…. Pick a server (or scan again, or type a URL: a fullhttp:///https://URL, ahost:port, or a bare host), then enter the API key (the prompt names the server you picked); both are stored in the credential. The credential takes priority over the environment for live requests and model refreshes, so a server behind a reverse proxy (ahttps://URL, or one with a path prefix) can be pointed at without touching your shell.Server discovery: at pi startup (on by default on Linux only — on macOS/Windows the scan's inbound UDP would make the OS firewall prompt about the node process; override with
"startupScan"in the configuration file) the extension runs a short background scan of the local network — the Lemonade UDP beacon (the same mechanism aslemonade scan) plus direct probes of localhost, the default gateway, and your local subnets. The result is kept for the session and offered in/login; Scan local network… (every platform) re-runs it. When the scan finds nothing it names the addresses it probed, so an empty result tells you exactly what to check. -
Environment Variables: Set
LEMONADE_API_KEY— orLEMONADE_ADMIN_API_KEY, which takes precedence when both are set, exactly like thelemonadeCLI (the server accepts the admin key on regular/v1endpoints too) — and pointLEMONADE_HOST/LEMONADE_PORTat the server.
Note: Provider-level configuration in models.json (like baseUrl or apiKey) is not supported because this extension uses dynamic discovery. Use the /login credential or environment variables instead.
Transport security: unless
LEMONADE_HOSTuses anhttps://scheme, the connection is plainhttp://— and a bearer key (fromLEMONADE_API_KEYor/login lemonade) then travels unencrypted on every request, including discovery. That is fine on your own machine (the default127.0.0.1), but for a server on another host set anhttps://LEMONADE_HOST(or tunnel it), and prefer/login lemonadeover putting the key in a shell environment where other processes can read it.
Select a model with any of:
pi --model 'lemonade/gemma-3-4b-it-GGUF'
pi --models 'lemonade/*' # cycle every Lemonade model with Ctrl+P
/model # inside the TUI, pick a lemonade/… entry
ctrl-l # with keybindingModel IDs containing forward slashes (/) or whitespace (common with cloud backends like OpenRouter) are automatically handled: the raw ID is sent to the server for compatibility, while a sanitized version is displayed in the TUI.
pi --list-models lemonade shows all discovered models with their context
window, output cap, thinking, and image support.
Note: The /model dialog shows the last-known catalog immediately and then
refreshes it in the background (a "Refreshing model catalogs…" status), so a
model you just downloaded usually appears the next time you open it — no
/reload needed.
The extension automatically discovers models downloaded on your Lemonade server. Only models with downloaded: true are included.
The extension first lists models via the OpenAI-compatible GET /v1/models endpoint, then queries each one via the Ollama-compatible POST /api/show endpoint to verify its capabilities. Only models supporting both completion and tools are registered:
/api/show capabilities |
Included? |
|---|---|
["completion", "tools", …] |
✅ Yes |
["completion", …] (no tools) |
❌ No |
["embedding", …] |
❌ No |
| Others (image, transcription, etc.) | ❌ No |
The vision capability is not a filtering criterion — it enables image input: such models are registered with input: ["text", "image"], so you can paste or drag images into the prompt and they reach the model as OpenAI image_url content parts. Caveats: vision models without an mmproj companion file fail on image input (lemonade-sdk/lemonade#955), and image tokens consume the text context window.
To optimize your workflow, models are automatically sorted by priority: pinned models first, followed by loaded models, then downloaded local models, and finally unloaded cloud models.
Two empty-looking outcomes are different and are reported differently:
- Server unreachable, or
/api/showcannot determine capabilities — a singlediscovery-failedfallback model is registered (no extra startup warning — the model name is the signal), so the provider still shows up in/modelwith an explanatory name. Start the server and run/reloadto repopulate. - Server reachable but nothing usable (no downloaded model, or none reporting
completion+tools) — the provider registers no models and shows an informational notice instead of a warning. Deleting your last model really does empty the list (and the cached copy); download one and run/reloadto repopulate it.
- At startup: Automatically performed whenever the
piCLI is launched (e.g., starting a TUI session or runningpi --list-models). Note: Initial discovery at startup cannot use the/logincredential and will rely on theLEMONADE_ADMIN_API_KEY/LEMONADE_API_KEYenvironment variables (or the default placeholder) and theLEMONADE_HOST/LEMONADE_PORTbase URL. - On demand: Triggered when you run
/reloadin the TUI,pi update --modelsin the CLI, or open/modelin the TUI (the dialog refreshes the catalog in the background while it is open). These actions trigger a model refresh that uses the/logincredential if available — the stored key and the stored server URL — taking priority over environment variables.
Note: Since pi update --models is a CLI command, it first performs the "At startup" discovery (using the env key) and then immediately performs the "On demand" refresh (using the login credential).
A discovery pass is bounded: every request shares the discovery timeout
(discoveryTimeoutMs in pi-provider-lemonade.json, default 20 s) on top of
its own 4-second timeout, and a refresh cancelled by pi
(/reload, interrupting a session) aborts the in-flight requests and skips the
/api/show batches that have not started. A pass that runs out of budget or is
cancelled degrades to the discovery-failed fallback (or keeps the previous
catalog on a refresh — pi then shows its standard "model catalog could not
be refreshed; using cached models" warning) — it never publishes a
half-queried model list.
Discovered models are cached in ~/.pi/agent/models-store.json for faster restarts and offline access.
The following properties are derived automatically and can be overridden in models.json:
| Pi Field | Source / Default |
|---|---|
contextWindow |
From /api/show → /v1/models → 128,000 |
maxTokens |
16,384 (reduced to what still fits in contextWindow — see below) |
cost |
From /v1/models cost_input_per_million / cost_output_per_million (USD/1M tokens; 0 for local models). Per-message usage.cost is priced from these rates |
reasoning |
true when the model reports the thinking capability, or when its id is in a family of builtin models with a verified thinking toggle (the catalog labels only some of them — see Thinking level control), false otherwise |
thinkingLevelMap |
{ off, medium } only by default, for reasoning models (see Thinking level control for the wider per-model maps) |
input |
["text", "image"] when the model reports the vision capability (image paste/drag in the TUI works; the llama.cpp backend loads the mmproj companion automatically), otherwise ["text"] |
compat |
supportsDeveloperRole / supportsStore false, maxTokensField: "max_tokens", plus builtin-model thinkingFormat heuristics (see Thinking level control) — all other compat flags resolve to their defaults |
The extension only reads the four Lemonade CLI environment variables
(LEMONADE_HOST, LEMONADE_PORT, LEMONADE_API_KEY,
LEMONADE_ADMIN_API_KEY) and its own
configuration file —
everything else
(model-specific context windows, output caps, reasoning overrides, compat flags,
even cost) is configured in ~/.pi/agent/models.json modelOverrides, just
like any other pi provider. This keeps configuration in one place and avoids
scattering extension-specific env vars across your shell.
All numeric, capability, and compat fields are set with sensible defaults at
discovery time and can be overridden per-model in
~/.pi/agent/models.json - no reload required; open /model, just like any other pi provider.
{
"providers": {
"lemonade": {
"modelOverrides": {
"Qwen3-4B-GGUF": {
"name": "Qwen3 4B (High Ctx)",
"contextWindow": 131072,
"maxTokens": 32768
}
}
}
}
}maxTokens and samplingParams overrides apply to the request itself:
maxTokens is sent as max_tokens on every request (otherwise the backend
applies its own default) and samplingParams keys are merged into the request
body last, so they override any named field — this is how backend-specific
knobs (llama.cpp/vLLM/SGLang endpoints) reach the wire. Three structurally
reserved keys (stream, model, messages) are skipped in that merge — they
must keep their computed values, and a samplingParams override of any of
them would corrupt the request. No other key is filtered: a
samplingParams entry named tools, tool_choice, max_tokens,
temperature, stream_options, store, or reasoning_effort silently
replaces the value the extension computed, so use samplingParams only for
keys the extension does not set itself. The output cap is reduced to the
tokens still available in contextWindow, with a margin that scales with
the window (floored at 256 output tokens) — a fixed margin would leave a
small-context model able to generate almost nothing.
Provider-level headers in models.json are also read by this extension:
the x-pi-provider-lemonade header selects the streaming backend — it is
deprecated in favor of streamingMode in the
configuration file (see
Choosing the streaming mode).
Models whose /api/show reports the thinking capability — or whose id
is in a family of builtin models with a verified thinking toggle (see the
builtin-models fallback below) — are registered with reasoning: true, so
Pi's thinking-level selector works: the selected level is sent with every
request. By default only two levels are exposed — off and medium —
matching the built-in llama.cpp provider: local templates understand at
most a boolean or a few levels, and the rest would be guessed. Three
builtin-model families accept a graded effort, and get a wider per-model
level map instead: Qwen3.8 (its template enforces a strict
xhigh/medium/low enum, so the accepted range is offered as
low/medium/xhigh — minimal and high are hidden as duplicates), and
gpt-oss and Muse-Glimmer (free-form effort strings, so the full
none/minimal/low/medium/high range is offered; xhigh is
hidden).
The wire payload depends on the model. The format is chosen by id heuristics for the Lemonade builtin models catalog (derived from the builtin models' chat templates — Lemonade does not expose the template itself; template-based detection is requested upstream in lemonade-sdk/lemonade#3644):
| Model ids | Format | What goes on the wire |
|---|---|---|
| Qwen3.8 | qwen |
top-level enable_thinking + reasoning_effort (the mapped level) |
| Qwen3/3.5/3.6 (except 3.8), MiniCPM (4.1/5/V-4.5+/o-4.5), GLM-4.5/4.7, Nemotron-3, Ornith-1.5, DeepSeek-V4, PromptBridge, SmolLM3, Gemma-4 | qwen-chat-template |
chat_template_kwargs: { enable_thinking, preserve_thinking } |
| DeepSeek-R1-Distill | deepseek |
thinking: { type: "enabled"/"disabled" } |
| LFM2.5 | chat-template |
chat_template_kwargs: { thinking } (boolean) |
| Muse-Glimmer | chat-template |
chat_template_kwargs: { reasoning_strength } (the selected level; omitted when off) |
| everything else | openai (default) |
reasoning_effort: "medium" / "none" — none is llama.cpp's hard per-request disable |
Why the formats differ (per-family rationale)
- Qwen3.8 uses
qwen(notqwen-chat-template): its template readsenable_thinkingand a strictreasoning_effortenum (xhigh/medium/low), and thinking defaults ON, so the server stripping a top-levelenable_thinking: trueis harmless; OFF goes through the server's disable path. - The
enable_thinking-kwarg families read the boolean, notreasoning_effort. Gemma-4 is not labelledreasoningin the catalog (its template defaults to thinking OFF), so the hint is inert there unless you overridereasoning: true— which then gives a working off/medium toggle. Gemma-3 is excluded: its/thinkprompt tokens have no kwarg equivalent. - DeepSeek-R1-Distill templates are static always-on, so the payload
is inert but consistent. Muse-Glimmer is always-on; its template
renders a free-form
Reasoning strength: <value>.system-prompt directive, so the strength is the only control it has. - The
openaidefault is correct for gpt-oss (itsreasoning_effortis a free-form string the template renders verbatim) and simply inert for the always-on families (Bonsai, Jan, Phi-4-mini-reasoning).offis not a validreasoning_effortvalue, which is why the level map does not copy the built-in provider'soff: "off".
The heuristics are best-effort and can misfire on a user model that shares
an id pattern with a builtin model but uses a different template — override
per-model in models.json if needed.
Models that report no thinking capability and whose id is not in a
toggle family of builtin models are registered with reasoning: false: no thinking
parameters are sent and the selector is hidden. A server that reports
thinking for no model at all is assumed to predate the capability, so
every model keeps the toggle. To re-enable the selector on a specific
model, override reasoning: true in models.json.
Builtin-models reasoning fallback. The catalog labels only some
thinking-capable models as reasoning, so a builtin model with a verified
template toggle gets reasoning: true automatically — the matching
thinkingFormat hint is already in compat, so no override is needed.
The family set is deliberately stricter than the format rules (always-on
families and non-thinking variants are out): a false positive would show a
thinking selector that does nothing.
To turn thinking off for one model or change the wire format, override it in
~/.pi/agent/models.json:
{
"providers": {
"lemonade": {
"modelOverrides": {
"gemma-3-4b-it-GGUF": { "reasoning": false },
"DeepSeek-R1-0528-Llama-3.1-8B-Instruct-GGUF": {
"compat": { "thinkingFormat": "deepseek" }
}
}
}
}
}thinkingFormat |
What goes on the wire |
|---|---|
"openai" (default) |
reasoning_effort (the selected level) |
"qwen" |
top-level enable_thinking: true/false |
"qwen-chat-template" |
chat_template_kwargs: { enable_thinking, preserve_thinking } |
"deepseek" |
thinking: { type: "enabled"/"disabled" } |
"chat-template" |
your chatTemplateKwargs, with {"$var": "thinking.enabled"} / "thinking.effort" placeholders expanded |
If a format sends fields your server rejects, the request fails server-side — start a fresh session (or drop the override) rather than expecting a fallback.
To provide these enhancements, the extension streams through a specialized
lemonade-completions layer by default (which still leverages the OpenAI
SDK for core communication) instead of pi's built-in
openai-completions implementation — see
Choosing the streaming mode.
- Automatic Retries: To ensure a smooth experience, the extension automatically retries chat completions with exponential backoff if it encounters HTTP 429 (Rate Limit) or 5xx (Server Error) responses — mid-stream or before the first chunk — honoring the server's
Retry-Afterheader when present. Once any content has been emitted, a failing stream is not retried (re-running it would duplicate the partial output); the error surfaces instead. - Detailed Error Messages: If a request fails, the Pi TUI now displays full diagnostic fields from the Lemonade server (such as status codes and error types), making it easier to diagnose connectivity or server-side issues.
- Stalled streams: the retries above cover the request phase and pre-content failures. If the server accepts a request, starts streaming, and then stops sending chunks without closing the connection (a backend that hung mid-generation), the stream has no idle timeout — the OpenAI SDK's request timeout is cleared as soon as the response headers arrive, so a stalled stream does not time out on its own. Abort the turn to interrupt it. A mid-stream stall watchdog is deliberately not implemented, to stay aligned with pi's own providers.
By default, requests are streamed through the extension's
lemonade-completions layer (the retries and diagnostics above). If you move
to a pi release that this extension has not been verified against and
streaming misbehaves — missing system prompt or tools, unexpected request
errors — set "streamingMode": "builtin" in
pi-provider-lemonade.json to use pi's built-in
OpenAI-compatible streaming instead, which is always generation-matched to
your pi:
{
"streamingMode": "builtin"
}Recognized values:
"extension"(default) — use the extension's layer."builtin"— use pi's built-in OpenAI-compatible streaming (no Lemonade-specific retries or diagnostics).
The mode is read when the extension loads (a /reload re-reads the file),
and the active choice is confirmed with a one-time startup notice.
Deprecated: the models.json x-pi-provider-lemonade header
The header in the lemonade provider's models.json headers still works
when the file does not set streamingMode ("openai-completions" = builtin,
"lemonade-completions" = extension). It is a private extension channel: pi
does not interpret it, and it rides on the request to the Lemonade server,
where it is ignored. The file value takes precedence, and a startup notice
points at the replacement.
While this package is open source, its development is not:
- Only npm-distributed files are kept in the GitHub repository. Development files (tests, type configs) are not published to npm or hosted on GitHub.
- Only issues are allowed; pull requests are disabled. If you find a bug or have a feature request, please open an issue.