@monroewilliams/pi-local
Pi extension for configuring multiple local LLM inference engine connections.
Package details
Install @monroewilliams/pi-local from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@monroewilliams/pi-local- Package
@monroewilliams/pi-local- Version
0.3.8- Published
- Sep 6, 2026
- Downloads
- 1,814/mo · 259/wk
- Author
- monroewilliams
- License
- MIT
- Types
- extension
- Size
- 89.9 KB
- Dependencies
- 0 dependencies · 2 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-local
A Pi extension for configuring and switching between multiple local LLM inference engine connections.
Features
- Multiple connections — configure several local inference servers (LM Studio, oMLX, llama.cpp, llama-swap, etc.) and switch between them
- Auto-detection — queries each endpoint for its models and shows whatever that server advertises: display name, size, context window, model type, and which models are loaded right now
- Model loading — load/unload models on servers that support it (oMLX, LM Studio, llama-swap)
- Persistence — your default provider and model are restored automatically on Pi restart
- macOS keychain — offers to store API keys in the macOS keychain via
securitycommands - Reasoning support — forwards pi's thinking levels as
reasoning_effort, using each server's advertised vocabulary where available
Supported backends
| Backend | Detection | Load/Unload | Metadata read |
|---|---|---|---|
| oMLX | /v1/models/status + /api/status |
Yes | alias, size, context window, max tokens, model type, reasoning vocabulary, pinned/favorite |
| LM Studio | /api/v1/models |
Yes | display name, size, context window, quantization, publisher, format, architecture, reasoning |
| llama-swap | /v1/models, recognised by owned_by |
Yes | display name, context window, load state, vision |
| OpenAI-compatible | /v1/models |
No | context window and size (llama.cpp), context window inherited by LoRA adapters (vLLM) |
The extension tries oMLX first, then LM Studio, then falls back to the generic
OpenAI listing. llama-swap answers that generic listing, so it is identified from
the response itself — every card it serves carries owned_by: "llama-swap" — and
that is what earns it load/unload, which the generic tier has nothing to call.
Servers disagree about where the same quantity lives, so each is read where it is
actually advertised: llama.cpp nests meta.n_ctx and meta.size, vLLM puts
max_model_len flat on the card (LoRA adapters inherit their base model's through
parent), and llama-swap mirrors the operator's capabilities.context into
meta.n_ctx alongside name, status.value and capabilities.
Load and unload
Each server is asked its own way:
| Backend | Load | Unload |
|---|---|---|
| oMLX | POST /admin/api/models/{id}/load |
admin session + POST .../unload |
| LM Studio | POST /api/v1/models/load |
POST /api/v1/models/unload |
| llama-swap | GET /props?model={id} |
POST /api/models/unload/{id} |
llama-swap has no load endpoint: dispatching a request at a model is what swaps its
server in. /props?model= is the cheapest route that dispatches — a GET for
properties, so no tokens are generated, and the process is up and health-checked when
it returns. Two quirks of doing it that way, both confirmed against a live server:
/propsis a llama.cpp route, so an upstream that is not llama.cpp answers 404. The model is loaded by then — the load is a side effect of dispatching, not of the upstream answering — so that 404 is not reported as a failure. llama-swap's own rejections are, and they are recognisable by their{"src":"llama-swap",...}envelope.- Unload answers
OKas plain text, not JSON, so nothing there parses a body.
llama-swap may swap out one model to load another; the picker does not model that, it re-queries after every action and shows what is actually running.
Reasoning levels
Thinking levels (/thinking) are forwarded as the OpenAI-style reasoning_effort
field on /v1/chat/completions. What each backend advertises decides which
levels you get:
| Backend | Levels | Wire format |
|---|---|---|
| oMLX | whatever /v1/models/status advertises (reasoning_effort_options) |
reasoning_effort, enable_thinking fallback |
| llama-swap | off, minimal, low, medium, high, xhigh |
reasoning_effort forwarded untouched |
| OpenAI-compatible (llama.cpp, vLLM, ...) | off, minimal, low, medium, high, xhigh |
reasoning_effort passed through |
| LM Studio | pi default (off … high) |
reasoning_effort |
llama-swap is treated like llama.cpp because it behaves like llama.cpp: it forwards
the request body untouched — reasoning_effort and chat_template_kwargs included,
confirmed by reading the forwarded request back out of its own capture endpoint — and
resolves the model name internally without changing what the upstream sees. (It rewrites
model only when the operator sets useModelName or a filter.) So thinking levels do
exactly what the engine behind it does, and everything below about llama.cpp applies to
llama-swap-fronting-llama.cpp. One llama-swap-specific thing to be aware of:
sendLoadingState: true (default false) makes it inject its own loading progress
into the reasoning field of a stream, so a thinking block can carry text that did not
come from the model.
For servers we cannot identify (llama.cpp, including llama-server), the level is
sent verbatim and off is sent as reasoning_effort: "none" — llama.cpp
treats "none" as "don't think" (enable_thinking = false) rather than
forwarding it to the chat template. Levels only take effect if the model's chat
template reads reasoning_effort; llama.cpp ignores it otherwise. The server
must not be started with --reasoning off (default auto is fine), and
--reasoning-effort on the command line only sets the default that a request
can override.
Commands
/local-endpoints
Add or remove connections. Each connection is identified by its base URL.
Manage Connections
> Remove: http://127.0.0.1:1234
Add new connection
Done
On macOS, if you enter a direct API key, you have the option to store it in the keychain. The key is then referenced via a !security command.
/local-model
Select a connection and model. Shows server stats where the server reports them (oMLX: version, loaded/loading counts, memory headroom) and, per model, its name, size, context window, model type and whether it is loaded.
Installation
You can either install this directly with the pi command via npm:
pi install npm:@monroewilliams/pi-local
or check out this repository and add the extension to your Pi configuration in ~/.pi/agent/settings.json (useful if you want to modify it to better suit your purposes):
{
"extensions": [
"/path/to/pi-local"
]
}
Then reload Pi (/reload) and run /local-login to add your first connection.
API key formats
The API key field accepts all Pi auth key formats:
| Format | Example | Description |
|---|---|---|
| Direct key | sk-1234567890abcdef |
Stored as-is |
| Environment variable | $MY_API_KEY or ${MY_API_KEY} |
Resolved via resolveConfigValue |
| Shell command | !security find-generic-password -s 'pi-local' -a 'http://...' -w |
Shell execution, stdout used |
| Empty | (leave blank) | No authentication |
On macOS, direct keys are optionally stored on the keychain.
Storage
- Connections — stored in
~/.pi/agent/auth.jsonkeyed by base URL - Default provider/model — stored in
~/.pi/agent/settings.json(defaultProvider/defaultModel) - Model metadata — cached alongside the connection in auth.json (display name, contextWindow, maxTokens, reasoning, reasoningEffortOptions, modelType, pinned, favorite) so the picker has something to show before the first live query
Development
npm install
npm run typecheck # TypeScript check
npm run check # Biome + TypeScript + tests
npm run format # Auto-format
License
MIT