localm's inference server exposes an OpenAI-compatible REST API plus a small set of localm-specific management endpoints.
localm serve <model> # default 127.0.0.1:8642, auto-bumps when busyAuthentication: open (no key required) until an API key exists - the
LOCALM_API_KEY env var, an owner key generated by the launcher/GUI, or any
scoped key minted with localm key create / the GUI's key manager /
POST /v1/keys. Once any key is configured, every route below requires
Authorization: Bearer <key> (or the GUI's session cookie) except GET /health, GET /localm-ca.crt, GET /whoami, and the three session routes
(GET /api/session, POST /api/session, POST /api/session/logout), which stay
public once a key exists. In open mode only GET /api/session is exempt from the
management gate: POST /api/session without the GUI shell token is a 403 from that
gate, and with it a 400 (there is no key to log in with). POST /v1/surfaces/gui and GET /v1/instances/status, POST /v1/instances/vouch and POST /v1/instances/cooperate-unload take no bearer key
either, but carry a credential of their own (an attach token or an instance
vouch; see SECURITY.md). Most authenticated
routes also check the caller's key for a specific scope (noted per section
below; the admin scope implies every other one). CORS is locked to
localhost by default; widen it with the cors_origins config key. See
tls.md before exposing the server beyond 127.0.0.1.
The OpenAI-compatible inference routes below (/v1/chat/completions,
/v1/completions, /v1/embeddings, /v1/rerank, /v1/audio/transcriptions and
/v1/images/generations) are the one exception: they accept a
cross-origin request from any local app - LM Studio/Ollama-style clients and
AI browsers included - without needing cors_origins widened first, so
pointing one at localm works the same way it would against LM Studio or
Ollama.
The Ollama HTTP API (/api/chat, /api/generate, /api/tags, ...) is served
on the same port; see ollama-api.md.
Open mode does not open the management routes. With no key configured, every
state-changing request and every GET under /api/ or /v1/ outside the
inference routes and the routes above that carry their own credential still
needs the GUI shell token or this instance's attach token as the bearer, from a
same-origin or Origin-less caller (or one in the cors_origins allowlist). The
config, keys, plugin, update and activity routes are among the gated ones. A
missing or wrong token is a 403 Open-mode management requires the localm GUI shell on this machine, or an API key; a cross-origin state-changing request is
refused earlier with a 403 Cross-origin request refused .... The exempt reads are every GET under /v1/models, GET /api/tags,
GET /api/ps, GET /api/version and GET /api/session. See
SECURITY.md.
A failed request answers {"detail": "<message>"}: 401 for a missing or
invalid key, 403 for a key without the route's scope (Key lacks required scope: <scope>), a cookie-authenticated change without its CSRF token, or a
cross-origin browser request to a route that does not accept one, and the
statuses listed with each route below. A defect in the server is a 500 with
{"detail": "Internal server error"}; its cause goes to the server log. A body
that fails validation is a 422 with {"detail": "<one sentence per field>", "errors": [...]}, where errors is the structured list. Three groups answer in
their own shape, and a body that fails validation is a 400 there:
the Ollama routes ({"error": "..."}), POST /v1/messages (Anthropic's error
object) and POST /v1/responses (OpenAI's error object).
Scope: any valid key (no specific scope required once auth is enabled).
Streaming and non-streaming chat. Standard OpenAI request body (see OpenAI request fields for what each field does here), plus localm request extras:
| Field | Notes |
|---|---|
top_k, repeat_penalty, min_p |
extra sampling controls; min_p (0 to 1) drops tokens less likely than min_p times the most likely one |
seed |
the sampling seed: the same seed repeats the same random choices (see Behaviour notes for why a reply can still differ) |
stop |
A string or a list of up to 16 strings (each up to 1024 characters). The reply is cut before the first match, the generation ends there, and finish_reason is stop. A stop sequence inside a model's <think> block is not applied. Also accepted by POST /v1/completions. |
tools, tool_choice, parallel_tool_calls |
OpenAI function tools; see Tool calling. |
grammar, grammar_lazy, grammar_triggers |
GBNF grammar constraining the output (local models); a lazy grammar stays unconstrained until a trigger pattern appears, and requires grammar_triggers |
cache_prompt |
Default true. false computes the reply with nothing kept from earlier requests: a GGUF model empties its KV cache and processes the whole prompt, and the reply runs on its own, after the replies already running (it streams waiting until then). The same request then gets the same reply whatever came before it or runs beside it, unless speculative drafting is on or the model's context window had to grow (or move its cache to system RAM) in between. Only true or false is accepted (a string or a number is a 422; a 400 on /v1/messages and /v1/responses). Also accepted by POST /v1/completions, POST /v1/messages and POST /v1/responses. Other backends keep no prompt cache and accept it with no effect. |
chat_template_kwargs |
{"enable_thinking": false} asks a reasoning model that uses <think> blocks or the harmony format (gpt-oss) to answer without reasoning. Only enable_thinking is applied and must be a boolean (otherwise 422); other keys are accepted and ignored. A model with none of these conventions is unaffected. |
While a streaming reply is being prepared, localm reports what it is doing in
chunks whose delta carries status (English text) and status_code (a
stable id): waiting (another request is running on this model), compacting
(older messages are being summarised to fit the context window), processing,
encoding_image, encoding_image_gpu, encoding_image_cpu,
vision_cpu_retry, encoding_audio_gpu, encoding_audio_cpu,
audio_cpu_retry, loading_model, recalling_memory,
downloading_embedder (the embedding model memory recall needs, on its first
download), chat_hooks (chat plugins' inlet hooks are running),
checking_grammar and generating. A streaming request that is compacted
carries X-Localm-Context-Compacted: 1; if the conversation still does not fit
after compaction, the refusal arrives in the stream as the reply text with
finish_reason: "error" instead of an HTTP 413.
A message may also carry untrusted_spans: [[start, end], ...] character
ranges of its own content that came from an untrusted source, such as a
fetched page or a tool result. localm tokenises those ranges with
special-token parsing off, so a chat-template control token spelled inside
them stays ordinary text instead of forging a role boundary. The field is
optional and additive: omit it and the request behaves exactly as before. A
range can only disable special-token parsing over your own prompt, never
enable it, and out-of-range values are clamped to the content length.
A message may also carry origin to say the client, not the user, wrote it.
Two values are accepted:
"tool": a tool result or a client note sent as ausermessage only because some chat templates require user/assistant alternation."client": text the client generated itself, such as the GUI's "Summarise the following conversation ..." prompt when it compacts a long chat.
localm then leaves that message out of what it treats as the user's own words:
the long-term-memory recall query and the user line of the audit/session log
(which memory consolidation learns from). A request whose only user messages
are marked recalls no memories. The model still sees the message. The field is
optional and additive: omit it (or send null) and the request behaves exactly
as before. Any other value is rejected with a 422, and a response message never
carries it.
The response's assistant message (and, for a stream, the delta) also carries
reasoning_content: the model's <think> reasoning, split out of the
visible content when the model emitted one. Present only when there was
reasoning to report; OpenAI clients that don't know the field ignore it.
{
"model": "mymodel",
"messages": [{"role": "user", "content": "What is the capital of France?"}],
"stream": true,
"temperature": 0.8,
"seed": 42
}The usage block carries exact token counts from the model's own tokenizer plus performance numbers, but which extra fields appear depends on streaming and on the endpoint:
{
"usage": {
"prompt_tokens": 14, "completion_tokens": 3, "total_tokens": 17,
"ttft_ms": 181.4, "tokens_per_sec": 38.2, "context_capacity": 8192
}
}prompt/completion/total_tokens |
tokens_per_sec |
ttft_ms |
context_capacity |
|
|---|---|---|---|---|
/v1/chat/completions, streaming |
yes | yes | yes | yes |
/v1/chat/completions, non-streaming |
yes | yes | yes | yes |
/v1/completions, streaming |
yes | yes | yes | no |
/v1/completions, non-streaming |
yes | no | no | no |
ttft_ms (time to first token) is reported on streaming responses and on
non-streaming chat completions.
context_capacity (the model's total context window in tokens) is reported
on both streaming and non-streaming chat completions.
Chat completions on a GGUF model with Multi-Token Prediction enabled
(mtp_enabled) also carry usage.mtp:
{"mtp": {"state": "on", "drafted": 180, "accepted": 151, "paused_steps": 0, "reason": null}}state is on (the reply speculated), paused (drafting was measured slower
than generating one token at a time and was paused for at least as many steps
as it ran), stopped (it stopped partway, or turned MTP off for the model;
reason says why), off (this reply could not draft at all: reason is
image for a turn with an image, audio for a turn with audio and no
image), idle (it drafted nothing, for
example on a very short reply) or unavailable (this model cannot speculate;
reason says why). drafted and accepted count the
draft tokens sent to verification and kept; paused_steps counts the steps
generated without drafting because drafting was slower. The field is null
when mtp_enabled is off.
With any draft source on (spec_source mtp, ngram or draft), chat
completions
also carry usage.speculation, the same object plus the source:
{"speculation": {"source": "ngram", "state": "on", "drafted": 96, "accepted": 71, "paused_steps": 0, "reason": null}}For mtp the other fields equal usage.mtp. For ngram, idle means the
reply drafted nothing, with reason not-paying when the measured step costs
held drafting back (otherwise nothing in the reply matched earlier text), and
unavailable carries the model status as reason (rewind-unsupported when
the model's cache cannot drop a rejected draft, ngram-cannot-pay when drafts
accepted 90% of the time were measured not to beat one-token decoding by 5%).
draft reports the same states; its unavailable reasons also include
draft-model-missing, draft-unsupported-role (the file is not a causal chat
model), draft-load-failed, draft-vocab-mismatch, draft-context-refused and
draft-cannot-pay (measured too slow to help at an 85% acceptance), and stopped carries draft-decode-failed:<code>
when a draft decode failed partway through the reply. An on reply carries
reason draft-on-cpu when the draft model runs on the CPU (the model runs on
the CPU, or the draft model did not fit in VRAM beside it). The field is null when no draft source is on.
| Field | Behaviour |
|---|---|
max_tokens, max_completion_tokens |
The reply cap; either name works. Both with different values is a 400. |
temperature, top_p, seed, stop |
Applied. The finish chunk, or the non-streaming choice, carries stop_sequence (a localm extension): the stop sequence that ended the reply, or null. |
presence_penalty, frequency_penalty |
Applied (-2 to 2): a token's logit drops by presence_penalty once it has appeared, and by frequency_penalty times the number of times it appeared. On a GGUF model the window is the last 64 tokens, as in llama.cpp. A penalty of 0 applies nothing. A diffusion language model cannot apply a non-zero value of them or of min_p (400). |
response_format |
See Structured output. |
tools, tool_choice, parallel_tool_calls |
See Tool calling. |
stream_options |
{"include_usage": true} moves the usage from the finish chunk to a last chunk with empty choices, sent just before data: [DONE]. Without it the finish chunk carries the usage. |
reasoning_effort |
"none" asks a reasoning model to answer without thinking (like chat_template_kwargs.enable_thinking: false, which wins when both are sent). Other levels leave the model's default. |
n |
Only 1 (one choice per request); any other value is a 400. |
logprobs, top_logprobs |
Returned for a GGUF model: see Log probabilities. top_logprobs is 0 to 20 and needs logprobs: true when above 0; anything else is a 400. |
logit_bias |
Not served (a non-empty map is a 400): token ids differ between models. |
functions, function_call |
The deprecated function-calling fields are a 400 (function_call: "none" is accepted); send tools and tool_choice. |
audio, modalities other than ["text"], web_search_options |
A 400. |
user, metadata, store, service_tier, prediction, verbosity, prompt_cache_key, safety_identifier |
Accepted; no effect. |
A message with role developer is read as system.
logprobs: true returns the log probability of each token of the reply's
content in choices[0].logprobs.content; top_logprobs adds that many of the
most likely tokens at each position, most likely first:
{"token": " blue", "logprob": -0.12, "bytes": [32, 98, 108, 117, 101],
"top_logprobs": [{"token": " blue", "logprob": -0.12, "bytes": [32, 98, 108, 117, 101]},
{"token": " red", "logprob": -2.31, "bytes": [32, 114, 101, 100]}]}A streamed reply carries the entries of each delta's content in that chunk's
choices[0].logprobs.content.
- The values are the model's own distribution at each step, a softmax over its
raw logits: before temperature,
top_k,top_p,min_p, the penalties or a grammar shape the choice. So the sampled token can rank below others, and a grammar can force a token with a very low value. - Only the content's tokens are listed. Tokens of the reasoning
(
reasoning_content), of tool calls and of text cut off by a stop sequence are left out; a token that spells both kept and cut text (the last one before a stop sequence, for example) is listed. bytesholds each token's own UTF-8 bytes. A character spread over several tokens appears in each of them, andtokenshows an incomplete character as U+FFFD.- A probability too small to represent is reported as
-9999.0. So is a token whose step produced logits that are not finite numbers (a fault in the model or its runtime); it comes with no alternatives, and the server log gets a warning. - GGUF models report them, except diffusion language models. A model that
cannot (a Hugging Face model, a diffusion model) refuses the request with a
400, and so does a streamed request while a plugin rewrites the stream. When a
chat plugin rewrites a non-streamed reply,
logprobsisnulland the log says why. - Each token is scored over the whole vocabulary, which slows generation:
on Qwen3-0.6B (a vocabulary of about 152,000 tokens) on a Radeon RX 6900 XT,
300-token replies ran at 321 tokens/s without
logprobs, 222 with them and 218 withtop_logprobs: 20, about 1.4 ms more per token. The added time per token follows the vocabulary size, not the model's, so it weighs most on small, fast models. On a model answering several requests at once, the replies decoding beside one that asked forlogprobswait for its scoring at each step too.
response_format constrains the reply, token by token, with a grammar:
| Value | Reply |
|---|---|
{"type": "text"} (or no field) |
Unconstrained. |
{"type": "json_object"} |
One JSON object. |
{"type": "json_schema", "json_schema": {"name": "...", "schema": {...}, "strict": true}} |
JSON matching the schema (the keywords listed for format in ollama-api.md). With strict: true a keyword that cannot be enforced (such as pattern) is a 400 naming it; otherwise that keyword is left out, the rest of the schema is still enforced, and the omission is written to the debug log. A keyword that gives the schema its shape (type, properties, required, items, anyOf, ...) is never left out: if it cannot be compiled where it is (for example type next to anyOf), the request is a 400 in either mode. Without schema the reply is any JSON object. |
A reasoning model answers without thinking when a format is set, unless the
request sends chat_template_kwargs: {"enable_thinking": true}; then the reply
may start with a <think> block of up to 1900 characters (returned in
reasoning_content) before the JSON. With tools and tool_choice: "auto" the
reply is either tool calls or the formatted JSON; with required or a named
function it is calls only and the format does not apply. response_format
cannot be combined with grammar, and a model whose backend cannot apply
grammars refuses it with a 400, as does a GGUF model whose grammar sampler
faulted earlier in this server's life (until localm restarts). A malformed
response_format is a 400, sent before any model is loaded.
tools takes OpenAI function tools (up to 64) and works with any chat model:
the model is told about the functions in its system prompt, and its calls come
back in message.tool_calls, with finish_reason "tool_calls". A streamed
reply sends each call whole in one delta.tool_calls entry carrying its
index, id, type and function; the call text never appears in content.
A reply can hold text before its calls, and more than one call.
| Request | Behaviour |
|---|---|
tool_choice |
auto (the default when tools is sent): the model may answer in text or call. none: the functions are not shown to the model and the reply is not read for calls. required: the reply is one or more calls. {"type": "function", "function": {"name": "..."}}: the reply is calls to that function. |
parallel_tool_calls |
false limits the reply to one call. |
| earlier turns | An assistant message with tool_calls (its content may be null) and the tool messages that follow it are given to the model as text, in order. A tool result is untrusted text: control tokens in it are defused and it cannot close its own fence. |
With required or a named function the arguments are constrained, token by
token, to the function's parameters JSON schema (the keywords listed for
format in ollama-api.md). A keyword that cannot be enforced
(pattern, bounds on a non-integer number, ...) is left out of that function's
schema and everything else in it is still enforced; a function whose schema
cannot be compiled, or a tool list too large for one grammar, takes any JSON
object as its arguments. Each case is written to the debug log. With auto the
arguments are constrained from the moment the model opens a call. A forced
choice starts with the call, so the model has no room to reason before it.
Whether a model opens a call under auto is the model's decision: a model
that does not follow the call instruction answers in prose, and required or a
named function is how to force a call. A call to a function that was not
offered is left in the text.
stop sequences apply to the visible text only, never inside a call, and a
call after a matched stop sequence is dropped. tools cannot be combined with
grammar (400). On a model whose backend cannot apply grammars, auto still
works, with calls read from the reply without a constraint, and required or
a named function is a 400. A llama.cpp runtime too old to apply lazy grammars
refuses auto at generation time: a non-streaming request gets a 400 that says
so, and a streaming one an in-band [inference error: ...] with finish_reason
"error". localm setup-llama installs a current runtime. A malformed tools or tool_choice is a 400.
Both generation endpoints answer failures the same way on both routes and in both streaming modes. Which of the two shapes you get depends on WHEN the failure happens, not on which endpoint you called:
| Failure | Reported as |
|---|---|
The request is refused before generation starts (unsupported input, an undecodable image, a grammar this model cannot apply, a malformed grammar or trigger pattern, malformed tools) |
An HTTP error status with the reason in detail: 400; 413 when the images of a request exceed the size or count limits, when the prompt does not fit the context window, or when an embedding batch is too large; 501 when the server lacks an image decoder, or an audio decoder for the clip's format; 503 when the trigger-pattern validator was too busy to check your pattern |
| Generation started and then failed (not enough free VRAM for this prompt, a conversation that outgrew the context window, a native decode error) | 200 whose text is \n[inference error: <reason>] (a leading newline, then the bracketed reason) and whose finish_reason is "error" |
The second row is a 200 on purpose. A streaming response has already sent its
headers by the time generation can fail, so a status is no longer available to
it; the non-streaming responses match their own streaming twin rather than
diverge from it. finish_reason is the machine-readable signal - check it
rather than the status code to detect a failed generation. A successful
generation never reports "error".
A streamed chat request whose preparation (loading the model, running the
chat plugins' inlet hooks, checking the request) takes more than a fraction of
a second opens its stream before preparing ends, so that it can send the
status chunks above. A refusal after that point cannot be an HTTP status. It
arrives as the reply text with finish_reason: "error", and the reply chunk
carries localm_error: {"status": <the HTTP status the refusal would have had>, "detail": "<reason>"} (a refusal while preparing puts it on the final
chunk too). A generation that fails before any text, with a status in the
table above, carries localm_error on its error chunk the same way. The
response headers of an early-opened stream (X-Localm-Memory,
X-Localm-Model-Routing, X-Localm-Context-Compacted) cannot be set once it is
open; they arrive in one chunk with an empty delta and a localm_headers
object, sent before the reply.
A 503 is the only status here that a retry can clear on its own; the rest
describe something about the request or the build that will fail identically
next time.
Absolute paths are redacted out of an [inference error: ...] body before it
leaves the server (a model that fails to load names its file, which would
otherwise carry the account name and install layout to the caller). The reason,
the file name and any line number are kept - the text is redacted, not muted.
An error that is neither of those - a genuine defect in the server - is a 500
with an opaque {"detail": "Internal server error"}, and its cause goes to the
server log rather than to the client.
When the memory plugin is active and recalls facts for a turn, the response also carries an X-Localm-Memory header (see Memory endpoints).
When the conversation is too long for the answering model's context window, the server compacts it for that request (older turns summarised, recent ones kept) and the response carries X-Localm-Context-Compacted: 1. Only the server's copy is compacted; a client that keeps the history itself can compact its own copy from then on instead of sending the full history every turn.
When a request leaves model unset, empty, or "localm", or names a model
with "pin_model": false, the model that would answer (the loaded one, or the
one named) is not automatically who answers: localm checks what the request
actually needs (an attached image, a request for structured tool calls, a
longer conversation than that model was trained to hold, or an explicit
required_capabilities list) against what that model can do, and answers
with an installed model that has it when it does not. A model you named is
never swapped out from under you - naming one pins it, gap or no gap, unless
you also send "pin_model": false.
Three localm extensions on the request body steer this:
| Field | Meaning |
|---|---|
pin_model |
false: model is only the preferred model, which routing may replace. true: whatever answers is never replaced, named or not. Unset: a named model is pinned, an unnamed one is not. |
required_capabilities |
Capabilities the answering model must have, any of vision, audio_input, tool_use, reasoning. Vision, audio input and context length never need listing: an image part, an audio part and the prompt's size already say so. |
min_context |
Tokens the answering model's trained context window must hold. The larger of this and what the prompt's own size implies applies. |
Only installed chat models whose file is present are candidates (never an embedding, diffusion, VAE, LoRA or projector entry), a candidate is only chosen when it is confirmed to have what is needed, and one that is already loaded is preferred. A routed model is loaded for the request without becoming the loaded model: the next request that needs nothing special is answered by the loaded model again. If a capable model fails to load, the next one is tried, then the model the request would otherwise use.
When no installed model has everything a request needs, what the request
cannot be answered without decides: an image goes to a model that can read
it, an audio clip to one that can take audio, and a conversation too long for the model goes to one that can hold it,
even if that model lacks a listed capability such as tool_use. unmet in
the routing header below names what it lacks. A request that only lists a
capability no installed model has stays with the model it would otherwise use.
The response body's own "model" field names the model that actually
answered whenever routing changed it; otherwise it echoes the name you sent
(or, for an unnamed request, the model that answered). Whenever there is
something to explain - a swap happened, or the model that answered was
missing something the request needed - the response also carries an
X-Localm-Model-Routing header, a compact JSON object:
{"resolved": "vision-model", "requested": "localm", "routed": true,
"pinned": false, "gaps": {"vision": "absent"}, "unmet": []}gaps maps each unmet or unknown capability to "absent" or "unknown" (a
model that has not been inspected yet); unmet lists what the model that
answered still lacks because no installed model could provide it, or none
could be loaded, in which case load_errors says why. A pinned request
reports its gaps with "pinned": true and an empty unmet. The header is
omitted entirely when nothing needs explaining.
A request that resolves to a model with an accepted peer route (see Using a model another instance has loaded) is forwarded to that instance, named the way that instance names it.
Multimodal input uses the standard multipart content format with base64
data-URIs ({"type": "image_url", "image_url": {"url": "data:image/..."}})
and needs a vision-capable model (a GGUF paired with a multimodal projector
via localm run --mmproj / localm pull --mmproj, or a HuggingFace-format
vision model). Sending an image while a text-only model is loaded no longer
just fails: if the request did not pin a model, localm looks for an installed
model that can see and answers with that one instead (see routing above).
Only when no such model is installed, or the request pinned the loaded
text-only model by name, does it reject the attached image with a clear
error.
Audio input uses the OpenAI input_audio part
({"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}})
and needs a GGUF model whose projector (mmproj) has an audio encoder, such as
Qwen3-ASR, or a HuggingFace-format model with an audio processor. The model
then transcribes or answers questions about the clip; it never replies with
speech. On a GGUF model, WAV (8, 16, 24 or 32-bit PCM, or 32/64-bit float, any
channel count) is always read and other formats (mp3, flac, ogg, ...)
need the voice extra; the clip is converted to mono at the rate the projector
expects, must be at least 0.1 seconds and at most 10 minutes long and 50 MB
encoded, and anything else, or audio that cannot be decoded, is a 400
saying why. Audio sent to a model that cannot
take it is routed or refused exactly like an image.
GET /v1/models/{id} carries audio_input (true or false) next to vision when its
files could be inspected.
When another localm instance on this machine already has a model loaded, chat and completion requests for it can be forwarded there instead of this instance loading its own copy into VRAM. It is always an explicit choice: the GUI offers it when you pick that model in the sidebar.
| Route | Scope | Purpose |
|---|---|---|
GET /v1/models/{model}/peer-offer |
models:read |
Whether a live instance has this model loaded. {"available": bool, "peer": {"instance_id", "host", "port", "scheme", "model", "requires_key"}}, with peer null when available is false. model is that instance's own name for it; requires_key is false for an instance with no API key. A lookup that takes too long is a 504. |
POST /v1/models/{model}/peer-route |
models:write |
Accept an offer. Body {"instance_id": "...", "api_key": "..."}, with api_key empty for an instance that has no key. The key is checked against that instance first: a refused key is a 403 and nothing is stored. Returns {"status": "routed", "model", "peer": {"instance_id", "host", "port", "scheme", "model", "peer_model"}}. An empty instance_id or no model to route is a 400 (an absent instance_id field is a 422); an instance that is no longer live, no longer has the model loaded or is not reachable over loopback is a 409, as is a route that would have this instance record the chat in a persisting session mode; a check that takes too long is a 504. |
DELETE /v1/models/{model}/peer-route |
models:write |
Stop forwarding. Returns {"status": "unrouted", "model", "was_routed": bool}; clearing a model with no route is a success. |
A model is matched by its file (the same path, the same sha256, or the same
file name and size), so a different model that merely shares a name is never
offered; an instance that reports only its active model's name is matched by
name. Any model that instance has loaded counts, not only its active one.
Only an instance reached over loopback, with its identity verified, is ever
offered or forwarded to. A route is dropped when that instance stops
answering or refuses the key it was given (the request that found out gets a
502 saying so); the next request loads the model here. Routes live in this
process's memory and do not survive a restart; GET /api/models lists them
under peer_routes.
A route keeps this instance's session persistence. Unless the chat is recorded
in full mode here, the other instance must confirm, when the route is
accepted and again before every forwarded request, that it records the chat
no more than this instance's mode allows (it reports this in the
session_mode field of GET /api/session for a request carrying the
X-Localm-Session-Mode header; it agrees to record less only for its owner
key, or for any caller when it has no key). Otherwise accepting is a 409 and nothing is stored, and a forwarded
request is a 409 saying why (naming that instance, and its mode when it
reports one); nothing is sent and the route stays. An instance that does not
answer this check, or refuses the key, makes accepting a 403 and drops an
existing route with a 502 as above. A
forwarded request carries X-Localm-Session-Mode. With a key that is not that
instance's owner key, it records at its own mode, which is checked before
each request; a change it makes to its mode while a request is running is not
covered.
Scope: any valid key (no specific scope required once auth is enabled).
Raw text completion (streaming and non-streaming), same request extras as
chat. Its usage block is narrower than chat's - see the table above.
prompt is one string, or a list holding exactly one string. echo: true
starts the reply text with the prompt. presence_penalty, frequency_penalty,
min_p and stream_options work as on chat. logprobs (0 to 20) returns the
legacy logprobs object for a GGUF model: tokens, token_logprobs,
top_logprobs (each a map of the most likely tokens, always including the
sampled one) and text_offset (where each token starts in text); a streamed
reply carries the entries of each chunk's text. The values are computed as on
chat (Log probabilities), for the tokens of the returned
text. echo together with
logprobs is a 400 (the prompt's tokens are not scored). A batch of prompts,
token-id prompts, best_of above 1, n other than 1, a non-empty logit_bias
and suffix are each a 400 naming the field.
Scope: any valid key (no specific scope required once auth is enabled). Anthropic clients
authenticate with x-api-key, which the API routes accept as well as Authorization: Bearer
(the bearer token wins when both are sent; the GUI login takes only the bearer form); the
anthropic-version and anthropic-beta headers are accepted and not needed.
An Anthropic SDK or an agent tool that speaks the Messages API can use localm by pointing its
base URL at the server (ANTHROPIC_BASE_URL=http://127.0.0.1:8642) and naming a localm model.
The request is answered by the same chat path as /v1/chat/completions, so capability routing,
compaction, tool calling and the response headers work the same way.
| Request field | Behaviour |
|---|---|
model |
A localm model name (localm or empty: the loaded model). Another name is a 404 not_found_error. |
max_tokens |
Required, the reply cap. |
messages |
user and assistant turns; content is a string or blocks: text, image (base64 or url source), tool_use (assistant), tool_result (user; text or image content, is_error marks a failed call). thinking blocks from earlier turns are dropped. Other block types (documents, server tool results) are a 400. |
system |
A string or a list of text blocks. |
stop_sequences, temperature, top_p, top_k |
Applied. |
cache_prompt |
localm's field, applied as on /v1/chat/completions. |
tools, tool_choice |
Custom tools (name, description, input_schema) work with any chat model, as in Tool calling. tool_choice auto, any, tool (by name) and none; disable_parallel_tool_use allows one call. Server tools (web search, code execution, ...) and mcp_servers are a 400. |
thinking |
{"type": "enabled"} (or adaptive, between_tools) lets a reasoning model think and returns its reasoning as a thinking block; without it, or with disabled, the model answers without thinking. budget_tokens is not applied. |
output_config |
format: {"type": "json_schema", "schema": {...}} constrains the reply to the schema, as a strict response_format does on chat (see Structured output). effort is accepted and not applied. |
stream |
Server-sent events in Anthropic's order: message_start, ping, per block content_block_start / content_block_delta (text_delta, thinking_delta then signature_delta, input_json_delta carrying a tool call's whole input) / content_block_stop, then message_delta with stop_reason and usage, then message_stop. message_start reports 0 input tokens; message_delta carries the real input_tokens and output_tokens. A generation that fails partway ends with an error event. |
metadata, service_tier, cache_control, inference_geo |
Accepted; no effect. |
stop_reason is end_turn, max_tokens, stop_sequence (with stop_sequence naming the match;
otherwise stop_sequence is null) or tool_use. Consecutive turns of the same role are joined,
and a turn's text blocks stay separate blocks, as the Messages API treats them. A thinking block carries an empty signature. Errors use Anthropic's shape,
{"type": "error", "error": {"type": "invalid_request_error", "message": "..."}}. The
type follows the status: authentication_error (401, a missing or wrong key),
permission_error (403), not_found_error (404), request_too_large (413),
rate_limit_error (429), overloaded_error (503, 529), invalid_request_error for
every other 4xx (a body that fails validation is a 400), and api_error for any other
status.
Same request fields as /v1/messages (without max_tokens); returns {"input_tokens": N},
counted with the model's own tokenizer and chat template, tool descriptions included, by the
model that would answer the same /v1/messages request (capability routing applies, and a model
another instance serves is counted there). The model is loaded if it is not already.
Scope: any valid key (no specific scope required once auth is enabled).
An OpenAI SDK or agent tool that speaks the Responses API can use localm by pointing its base URL
at the server (OPENAI_BASE_URL=http://127.0.0.1:8642/v1) and naming a localm model. The request
is answered by the same chat path as /v1/chat/completions, so capability routing, compaction,
tool calling, structured output and the response headers work the same way.
| Request field | Behaviour |
|---|---|
model |
A localm model name (localm or empty: the loaded model). |
input |
A string, or a list of items: messages (user, assistant, system, developer; content as a string or input_text, output_text, input_image with an image_url, and input_audio parts), function_call and function_call_output items (an output is a string, or input_text and input_image parts; the images reach the model in a user message after the tool results). reasoning items are dropped. input_file parts, item_reference and other item types are a 400. |
instructions |
A system message for this request only; it is not carried into a continuation. |
tools, tool_choice, parallel_tool_calls |
Function tools work with any chat model, as in Tool calling. tool_choice auto, none, required or a function by name. Built-in tools (web search, file search, code interpreter, ...) are a 400. |
text.format |
text, json_object, or json_schema (with strict), as response_format on chat (see Structured output). |
max_output_tokens, temperature, top_p |
Applied. |
reasoning.effort, reasoning.summary |
effort: none turns thinking off; another value leaves the model's default. A reasoning model's thinking comes back as a reasoning item whose content is the full reasoning text; its summary stays empty whatever reasoning.summary asks. |
previous_response_id |
Continues a stored response: its input and output are sent ahead of this request's input. Responses are kept in the server's memory only, never on disk, are lost when the server restarts, and are visible only to the key that created them. The store holds at most 256 responses and 64 MB of conversation JSON; past either bound, the oldest response of the key holding the most is dropped, so one key filling the store evicts its own responses first. A response expires an hour after it is stored and is removed at the next request to this route after that. A response too large to keep comes back with store: false. An unknown, expired, dropped or foreign id is a 404. |
store |
false keeps the response out of the store. |
stream |
Server-sent events: response.created, response.in_progress, per output item response.output_item.added, response.content_part.added, response.output_text.delta (or response.reasoning_text.delta), response.output_text.done, response.content_part.done, response.output_item.done, function calls as response.function_call_arguments.delta and .done carrying the whole arguments, then response.completed (or response.incomplete when the token cap was reached, response.failed when generation failed). Every event carries a sequence_number. |
include |
reasoning.encrypted_content and the built-in tool entries (file_search_call.results, web_search_call.results, web_search_call.action.sources, message.input_image.image_url, computer_call_output.output.image_url, code_interpreter_call.outputs) are accepted with no effect; a reasoning item carries no encrypted content. message.output_text.logprobs returns the log probabilities of the reply's text (see top_logprobs below). Any other value is a 400. |
truncation |
auto or disabled; either way an over-long conversation is compacted as on chat. |
metadata, user, prompt_cache_key, safety_identifier, service_tier, max_tool_calls |
Accepted; metadata is echoed, the rest have no effect. |
| localm chat fields | top_k, min_p, repeat_penalty, presence_penalty, frequency_penalty, seed, stop, grammar (with grammar_lazy, grammar_triggers), required_capabilities, pin_model, min_context, chat_template_kwargs and cache_prompt (for example through an SDK's extra_body) are applied as on /v1/chat/completions. Other unknown fields have no effect and are named in the debug log. |
top_logprobs |
0 to 20, anything else is a 400. With include: ["message.output_text.logprobs"], each token of the reply's text comes with that many of the most likely alternatives in the output_text part's logprobs (and, streamed, in each response.output_text.delta event, holding the entries the chat stream sent with that piece of text, so a token split across two deltas comes with the first, and in the response.output_text.done event, which carries all of them), in the shape of Log probabilities: token, logprob, bytes, top_logprobs. Without the include it has no effect and logprobs stays []. The rules and limits are the chat route's: a GGUF model reports them, and a model that cannot (or a streamed request while a plugin rewrites the stream) is a 400 naming the model; a reply a chat plugin rewrote comes back with logprobs: []. |
background, conversation, prompt, text.verbosity other than medium |
A 400 naming the field. |
Only POST /v1/responses is served: a stored response cannot be fetched, listed, cancelled or
deleted through the API. Errors use OpenAI's shape, {"error": {"message": "...", "type": "invalid_request_error", "param": ..., "code": null}}. The type follows the status:
authentication_error (401, a missing or wrong key), permission_error (403),
not_found_error (404, including an unknown previous_response_id, which sets param
to that field), invalid_request_error for every other 4xx (a body that fails
validation is a 400), and server_error for any other status.
Scope: any valid key (no specific scope required once auth is enabled).
{"model": "mymodel", "input": ["text one", "text two"]}Returns {"object": "list", "data": [{"object": "embedding", "index": 0, "embedding": [...]}], "model": "...", "usage": {"prompt_tokens": N, "total_tokens": N}}. encoding_format is float (default) or base64 (each
vector as little-endian float32 bytes, base64-encoded); anything else is a 400.
A model registered as an embedding model (or the configured embedding_model)
is served by the dedicated embedder, whose usage counts are 0.
Errors: 400 when model is empty and there is no active or startup chat model, or (dedicated embedder) for an input the tokenizer refuses; 422 when the loaded model cannot
embed, when the embedding model is unavailable (localm setup-embeddings), or
when model names a reranker; 503 when the embedder worker fails. Against a
HuggingFace-format model, 413 when the request exceeds the configured
text-count or character-count cap (hf_embed_max_texts, hf_embed_max_chars).
Scope: any valid key (no specific scope required once auth is enabled).
{"model": "bge-reranker", "query": "what is a panda?",
"documents": ["The giant panda is a bear.", {"text": "Pandas is a Python library."}],
"top_n": 2, "return_documents": false}Scores each document against the query with a registered reranker (a GGUF cross-encoder such as bge-reranker-v2-m3, or a decoder reranker such as Qwen3-Reranker) and returns them best first, in the Jina / Cohere / llama-server shape:
{"object": "list", "model": "bge-reranker",
"usage": {"prompt_tokens": 61, "total_tokens": 61},
"results": [{"index": 1, "relevance_score": 2.24}, {"index": 0, "relevance_score": 1.58}]}index is the document's position in the request; a higher relevance_score is
more relevant, on a scale that belongs to the model (bge returns an unbounded score,
Qwen3-Reranker the probability of "yes"). top_n keeps the best N and
return_documents adds "document": {"text": "..."} to each result. A classifier head with several outputs
also reports them as label_scores, and the first is the relevance score. A document
longer than the model's window is cut to fit and its result carries "truncated": true.
model names a registered reranker and may be left out when exactly one is
registered; a path is never accepted. One reranker is resident at a time and a
request for another replaces it. Errors: 400 for an empty query, a query too long
for the model's window, or several rerankers with no model; 404 for an unregistered
model or no reranker at all; 422 for an invalid body (including a top_n below 1 or
an empty documents list), a model that is not a reranker (an embedding or chat
model), a registered reranker whose file is missing, or a file without a classifier
head; 503 when the model cannot be loaded or its worker fails.
Scope: voice. Served by the voice plugin, so the route exists while that plugin is
enabled. It answers with Whisper (pip install "localm[voice]"), or with an installed
GGUF model that hears audio (for example localm pull ggml-org/Qwen3-ASR-0.6B-GGUF:Qwen3-ASR-0.6B-Q8_0.gguf), described below. The official
openai SDK works against it:
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8642/v1", api_key="<key, or any text in open mode>")
with open("speech.wav", "rb") as f:
result = client.audio.transcriptions.create(model="whisper-1", file=f)
print(result.text)A multipart/form-data upload. The audio is decoded and transcribed in memory by the
speech worker and is never written to disk, in any session mode, and the file name is
never used as a path.
| Field | Meaning |
|---|---|
file |
Required. One audio file, any format PyAV decodes (wav, mp3, m4a, ogg, flac, webm). At most 25 MB; larger is a 413 before it is fully read. |
model |
Optional. whisper-1 or the configured voice_stt_model for Whisper, or the name of an installed model that hears audio. A registered model that cannot take audio is a 400; any other name is a 404 naming what this server transcribes with. |
language |
Optional ISO 639-1 code such as en. Detected when omitted. An unsupported code is a 400. |
prompt |
Optional text that biases the transcript (names, jargon). |
response_format |
json (default, {"text": ...}), text, srt, vtt, or verbose_json (task, language name, duration, text, segments with timing and confidence). |
temperature |
Optional, 0 to 1. |
timestamp_granularities[] |
segment and/or word, with verbose_json only. word adds a words list; asking for word alone omits segments. |
Which engine answers. Whisper answers whenever faster-whisper is installed and model
is left out or is a Whisper name. An installed model that hears audio answers when
model names it (even with faster-whisper installed), and when faster-whisper is not
installed and model is left out or is a Whisper name; with several such models, one
that is already loaded is used, else the first by name. The response carries
X-Localm-Transcription-Model with the model that answered. That path gives json and
text only (the model returns plain text without timestamps; srt, vtt and
verbose_json are a 400), takes WAV without any extra and other formats with the voice
extra (501 otherwise), accepts clips up to 10 minutes and 25 MB, decodes at 0.0
temperature unless temperature is sent, passes language and prompt to the model as
part of its instruction (a dedicated speech model such as Qwen3-ASR ignores them), and answers 413 when the clip does not fit the model's context
and 502 when the transcript hit the reply limit instead of returning it cut short. The
audio is not recorded in the audit log or the transcript.
stream, include[], chunking_strategy and known_speaker_* change the output and
are not implemented, so sending them is a 400; other unknown fields are ignored. A
recording with no speech is a 200 with empty text. Failures: 400 an unreadable
file or bad field, 403 a cross-origin browser request from an origin that is not
this server, a local page or a cors_origins entry, 413 too large, 415 not
multipart, 501 the speech package is not installed and no installed model hears
audio, 409 the first-use model download is blocked by the network policy, 503
the speech engine could not start or load its model, 504 the speech engine hung and
was restarted, 500 the audio decoder failed, 502 any other engine failure.
Scope: image. Served by the image plugin (ComfyUI today), which ships disabled:
enable it on the Plugins page first. The route exists while the plugin is enabled.
result = client.images.generate(model="any", prompt="a lighthouse at dusk",
size="1024x1024", response_format="b64_json")A JSON body. The request waits for the generation (it runs as the same background job
as POST /api/imagine, so it shows in the activity list and frees and restores VRAM the
same way) and answers {"created": <unix time>, "data": [{"b64_json": ...} | {"url": ...}]}.
| Field | Meaning |
|---|---|
prompt |
Required, up to 32000 characters. |
n |
1 to 4 images, generated one after another. |
size |
WIDTHxHEIGHT (each side 64 to 2048 and a multiple of 8, at most 2048 x 2048 pixels) or auto. Omitted or auto keeps the workflow's own size. A workflow with no EmptyLatentImage or EmptySD3LatentImage node cannot take a size and the request fails saying so. |
response_format |
b64_json or url. |
output_format |
Only png (the default). |
model, quality, style, user |
Accepted and ignored: the image model is the one the active workflow loads. |
Outside privacy mode the image is kept in the gallery with its sidecar. With an API key
configured, response_format defaults to url: the gallery file route
(/api/imagine/file/<name>), fetched with the same key. With no key configured that
route cannot be fetched by an API client, so b64_json is the default and asking for
url is a 400. In privacy mode nothing is kept: the image is generated into a
private directory, returned as b64_json (the default there; asking for url is a
400), and the directory is deleted before the response is sent.
Failures: 400 an empty or over-long prompt, a bad size, an output_format other
than png, a bad response_format, or url where none can be served (above);
422 an n outside 1 to 4 or a body that does not validate; 409 the job was
cancelled (for example through the job's cancel route); 502 the generation failed (the
reason is in detail); 503 the server has no background job registry or could
not determine its own address; 500 in privacy mode, the generated image could
not be deleted, so it was not returned.
Scope: tts. Served by the tts plugin. Speaks text with a registered text-to-speech
model: a Qwen3-TTS GGUF and its mmproj (localm pull ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF:Qwen3-TTS-12Hz-1.7B-Base-Q4_K_M.gguf fetches
both). The browser voices of the tts plugin are separate and unaffected.
audio = client.audio.speech.create(model="tts-1", voice="default",
input="Hello there.", response_format="wav")
audio.write_to_file("hello.wav")A JSON body, or multipart/form-data with the same fields plus an optional voice_file.
The request waits for the whole file: it runs in the isolated speech worker as a
background job of kind speak, so it shows in the activity list with its progress, and a
client that disconnects cancels it. The audio is returned from memory and never written
to disk, in any session mode. The seed used is in the X-Localm-Seed response header.
| Field | Meaning |
|---|---|
input |
Required, 1 to 4096 characters. Text containing the model's control-token strings (such as <|im_end|>) is a 400. |
model |
A registered model of type tts. Omitted, localm, tts-1, tts-1-hd and gpt-4o-mini-tts name the only one; with several registered, name one. |
voice |
default (the model's own voice), or the name of a WAV recording saved as <name>.wav in the voices folder of the data directory. Any other name is a 400 listing the voices. |
voice_file |
Multipart only: a WAV recording (PCM or float, at most 30 s and 16 MB) whose voice to imitate. Not together with a named voice. |
response_format |
wav (default; 16-bit mono at 24 kHz) or pcm (the same samples, raw little-endian, no header; a model that speaks at another rate than 24 kHz gets a 400 for it). |
language |
Optional code such as en or name such as english; the model's default when omitted. |
seed |
Optional, 0 to 4294967294, for a reproducible result. Random when omitted. |
speed, instructions, stream_format |
Only 1.0, empty and audio: other values change the output and are not implemented, so they are a 400. |
mp3, opus, aac and flac are a 400; other unknown fields are ignored. Failures:
400 a bad field, text or recording, 403 a cross-origin browser request from an origin
that is not this server, a local page or a cors_origins entry, 404 no such model, 422 a model that is not a
text-to-speech model or lacks its mmproj, 413 too large, 501 the llama.cpp runtime
or the model cannot synthesize speech (run localm setup-llama for the pinned runtime),
503 the model failed to load, 504 the speech worker hung and was stopped, 502 the
generation failed or did not end within its frame budget, 409 cancelled.
Scope: models:read.
Lists every registered model (and the startup model when it is not
registered) as {"object": "list", "data": [{"id", "object": "model", "created", "owned_by", "loaded", "active"}]}. GET /v1/models/{id} (same
scope) returns registry detail for any registered model: id, object,
owned_by, path (basename only), source, sha256, size_bytes, aliases,
active, loaded, model_type, plus model_type_recorded: false when the
registry entry records no type, vision and audio_input when the model's files
could be inspected, adapters when LoRA adapters are applied to the loaded
model, and peer_route when an accepted route forwards it to another instance.
An unregistered {id} is a 404.
Public - no authentication, even when a key is configured. 200 with
{"status": "ok", "model": "...", "loaded": true|false}; 503 when no engine is
initialised.
Scope: models:write.
POST /v1/models/unload?model=<name> releases model(s) from VRAM (e.g.
before image generation hands the GPU to ComfyUI; omit model to unload
everything currently loaded); POST /v1/models/load?model=<name> reloads one
(omit model for the active or default model). Both wait for any in-flight
generation to finish first, and for the freed VRAM to actually be reclaimed
before returning.
POST /v1/models/load returns:
{"status": "loaded", "model": "mymodel",
"gpu_layers_offloaded": 24, "gpu_layers_total": 32, "degraded": true}status is "already_loaded" when the model was resident already, or
"peer_routed" (with model and peer, and no load) when an accepted route
forwards the model to another instance. The
gpu_layers_offloaded/gpu_layers_total/degraded fields appear only when
the backend can report per-layer placement (GGUF models, once the load has
completed), with moe_cpu_layers when a Mixture-of-Experts load kept the routed
experts of that many layers in system RAM (which also makes degraded true). A model too large to fully fit VRAM still loads deliberately,
offloading as many layers as fit and running the rest on CPU rather than
refusing outright; degraded is true whenever fewer than the full layer
count landed on the GPU, so a caller can tell that apart from a full GPU
load. parallel_slots appears when the loaded model answers more than one
request at once, and says how many. A load that reports its memory-mapping also
carries use_mmap, mmap, mmap_from_disk and mmap_cpu_from_disk, with
mmap_note and mmap_ram_short when there is something to say, and adapters
lists the LoRA adapters applied. Without model and with nothing active,
loaded or configured to fall back to, the load is a 503; an unregistered name is a
404.
POST /v1/models/unload with no model returns status ("unloaded",
"in_use" when every loaded model was mid-request and none could be freed, or
"already_unloaded"), unloaded_models, embedder_unloaded, and
skipped_in_use when something was pinned by an in-flight request. It adds
confirm_required (what is still using a model) when a model stayed loaded
because it was still in use after a short grace period; this route has no
force parameter to override that. With ?model=<name> it returns
{"status": "unloaded", "model": ..., "was_active": ...}, or
{"status": "already_unloaded", "model": ...}, or
{"status": "confirm_required", "model": ..., "detail": ...} when the model
was still in use after the grace period and was left loaded. Treat anything
other than "unloaded" or "already_unloaded" as "the model is still
resident. An unregistered model is a 404. The unload routes (not the load route) add vram_freed,
vram_before_bytes, and vram_after_bytes when a GPU free-memory reading is
available, plus vram_reading_uncertain and a vram_note when that reading
cannot be trusted (a stale probe, a driver that reports only this process's own
allocations, or an unmeasurable post-unload reading). Unloading is implicit-recovery: the next chat request against an
unloaded model reloads it automatically.
POST /v1/models/rename?model=<name>&new_name=<name> renames a registered
model in place: re-keys the registry entry and, if the model is currently
loaded, its in-memory engine, so it keeps serving under the new name with no
reload. Unlike an alias, the old name stops resolving. It returns
{"status": "renamed", "model": "<old name>", "new_name": "<new name as sanitised>", "notes": [...]}, where notes reports the references to the old
name it migrated or could not (a per-project coder config, for example); an unregistered model is a 404, a taken name a
409, and a failed rename a 400. This is what localm rename calls; it exists on the always-present /v1 surface (not only under
localm gui) because a headless localm serve has no GUI routes to do it
from instead.
Scope: config:write.
POST /v1/server/restart unloads every resident model, waits for the freed
VRAM to actually be reclaimed, then restarts the process in place on the
same port (a fresh model load right after re-exec would otherwise race the
still-reclaiming GPU driver). An API-only instance that a second localm gui
attached to comes back with the GUI mounted again. POST /v1/server/shutdown
unloads and exits without relaunching - a clean alternative to force-closing
the window or a Ctrl+C that does not always land. Both return immediately
({"restarting": true} / {"stopping": true}) while the actual
restart/shutdown runs a moment later, so the response reaches the client
first.
Scope: config:read for GET (also covers GET /v1/config/schema,
GET /v1/media/config, GET /v1/tts/config, GET /v1/plugins/settings and
GET /v1/comfy/status); config:write for PATCH (also covers
POST /v1/media/config/{name}, POST /v1/tts/config,
POST /v1/plugins/{name}/settings, POST /v1/comfy/stop, and
POST /v1/comfy/restart).
Read and update <data dir>/config.json. PATCH accepts only known keys and
persists immediately; engine values (context sizes, GPU layers) apply on the
next model load. These settings widen a trust boundary and are hidden from, and refused for, a
non-owner config:write key - only an admin-scoped key or the open-mode
owner may read or change them: allow_network_drives, binary_dir, bind_host, browser_allow, browser_custom_domain_rules, browser_deny, browser_enabled, browser_engine, bugreport_upload_token, bugreport_upload_url, chat_mode, civitai_api_key, coder_mode, coder_reviewer, coder_reviewer_model, coder_untrusted_provenance, comfy_api_url, comfy_launch_cmd, comfy_workdir, cors_origins, embedding_model, gui_preview_enabled, gui_preview_owner_only, gui_proxy_remote_images, hf_token, hf_trust_remote_code, keep_diagnostics, llama_runtime_history, llama_runtime_pin, metrics_enabled, mode, net_allow_model_downloads, net_allow_private, net_deny, net_search_url, rag_allowed_roots, rag_denied_roots, rag_indexing_mode, require_auth, spec_draft_model, tls_cert, tls_enabled, tls_key, update_allow_prerelease, update_ignore_net_policy, update_token, update_url.
Two further keys, plugins and plugins_enabled, are plugin STATE rather than
settings: this endpoint cannot validate what is inside them, and their own write
surfaces enforce stronger permissions (POST /v1/tts/config and POST /v1/media/config/{name} require an owner for the fields that become a script
URL, a shell command, or a render target; POST /api/plugins/{name}/enable
requires plugins:admin). PATCH therefore refuses both to a non-owner
config:write key, with a 403 naming the endpoint to use instead, so this
general route cannot be used to bypass a specific one. Their values stay
readable.
GET /v1/config returns the whole config plus seven read-only extras:
effective_mode, effective_chat_mode, effective_coder_mode,
effective_ctx_max, effective_ctx_model, instance_id and instance_port.
PATCH ignores those seven, so a client can send back what it read, and returns
the full merged config; a non-owner caller gets neither the owner-only keys nor
their values. hf_token and civitai_api_key are kept in a separate owner-only
file, not in config.json, and are written last, so an error leaves them
unchanged. A change to embedding_model that would leave an existing
collection's vectors unusable is not applied by the first request: it answers 200
{"needs_confirm": true, "model", "collections", "note"}, and the same body with
"confirm": true applies it. PATCH errors: 400 for an unknown key, an invalid
value, or require_auth: true while no API key exists; 403 for an owner-only
key or plugin state from a non-owner key; 409 when config.json or the
credentials file exists but cannot be read; 504 when the save times out; 500
when the credentials file cannot be written.
GET /v1/config/schema returns {"fields": [...]}. GET /v1/media/config
returns {"plugins": [{"plugin", "label", "fields"}]} for image, music and
video; POST /v1/media/config/{name} merges one plugin's block (a blank field
clears its override) and returns {"plugin", "fields"}, with 404 for an unknown
plugin, 403 for an owner-only field from a non-owner key, 400 for an invalid
value, 409 for an unreadable config and 504 on a timeout. GET and
POST /v1/tts/config return {"plugin", "active", "fields"} and fail the same
way (no 404). GET /v1/comfy/status returns {"alive", "launched_by_localm"};
POST /v1/comfy/stop and POST /v1/comfy/restart return {"ok", "message"};
all three answer 504 when they time out.
Scope: config:read for GET, config:write for POST.
The generic counterpart to /v1/tts/config and /v1/media/config/{name} for
a plugin that contributes settings dynamically via host.add_settings()
rather than through a bespoke schema (the Open WebUI Valves interop seam -
see plugin-interop.md). GET returns every ACTIVE
plugin's declared fields with their resolved values; an inactive or unknown
plugin simply has no entry. POST merges a plugin's block key by key (a
blank field clears that override back to its declared default) and 404s for
a plugin with no active add_settings() fields. GET returns
{"plugins": [{"plugin", "label", "fields"}]} and POST returns
{"plugin", "fields"}; POST also answers 400 for an invalid value, 409 for an
unreadable config and 504 on a timeout. A field marked owner-only in
the plugin's own declaration is hidden from, and refused for, a non-owner
config:write key, the same rule /v1/config applies to its own admin_only
keys.
These routes are part of the base server, registered unconditionally. They are
present on a headless localm serve with no plugins installed, not only under
localm gui. /v1/server/restart and /v1/server/shutdown are documented under
Server control above.
| Route | Scope | Purpose |
|---|---|---|
GET /api/issues |
config:read |
List the project's issues through the bug-report proxy. Takes ?state= (open, closed or all; default and fallback all). Returns {"available": bool, "issues": [...]}, with available false when no tracker is configured, and an error string when the proxy fails. |
GET /api/update/check |
config:read |
Ask whether a newer build is available. Does not apply anything. Returns {"available": true, "current", "latest", "newer", "comparable", "notes", "published_at", "asset", "signature"}; {"available": false} when no updater is configured; {"available": true, "error": "..."} when the check fails. |
GET /api/changelog |
config:read |
Return the shipped CHANGELOG.md. Local file read, no network call, works offline. Returns {"available": true, "version", "markdown"} with the released history and without the [Unreleased] section, or {"available": false} when the file is absent from this build. |
POST /api/update/apply |
config:write |
Apply an available update. See SECURITY.md for the signing and rollback model. Returns {"applied": false, "reason": "already up to date", "current"} when there is nothing newer, {"applied": false, "error": "..."} when the apply failed and was rolled back, and otherwise {"applied": true, "version", "klass", "backup", "restart_needed", "available": true} plus "restarting": true for every class except a setup-class update. 400 when the updater is not configured or the release has no downloadable build; 502 when the check for the latest release fails. The backup path and any error text carry the full path for the owner and open mode, and a scrubbed one (data dir as <data>, file name kept) for a scoped key. |
GET /api/update/rollback |
config:read |
Whether a rollback is possible, and which build it would restore. Read-only; never rolls anything back. Returns {"available", "backup", "version", "current"}, with version null when the backup does not record one. backup is the full path for the owner and open mode, and a scrubbed one for a scoped key. |
POST /api/update/rollback |
admin |
Restore the previous build from the last update's local backup and restart into it. No signature check and no network request - it restores files, not a download. Requires admin specifically (not merely config:write), and restores files only, not a deps-class update's package installs. Returns {"rolled_back": true, "backup", "version", "restarting": true}; 403 for a key without admin, 409 when there is no backup or an update or rollback is already running, 500 when the restore failed partway (the backup is kept). Only open mode or an owner key reaches it, so its backup path and error texts are never scrubbed. |
POST /api/bug-report |
config:write |
Generate and save a bug report for review. |
POST /api/bug-report/send |
config:write |
File a saved, reviewed bug report. |
A proxy failure is reported in the body, not the status. GET /api/issues
and GET /api/update/check answer 200 with an error string when the proxy
cannot be reached, rather than a 5xx. Check for error rather than branching on
the status code alone. They are not blanket "always 200" routes though: the
scope dependency still rejects an unauthorised caller before the handler runs.
net_mode: off stops GET /api/issues and GET /api/update/check. Each
answers 200 with an error naming the setting (the update check still runs when
the owner-only update_ignore_net_policy is on), and POST /api/update/apply
fails with its 502 for the same reason. GET /api/changelog makes no network
call at all.
Scope: admin. Off by default.
Prometheus text-format metrics. The route does not exist until you turn on the
metrics_enabled setting (Settings > Server, or localm config metrics_enabled true; LOCALM_METRICS=1 does the same for one run) and restart. With it off
nothing is collected and /metrics answers 404.
| Metric | Type | Labels |
|---|---|---|
localm_http_requests_total |
counter | method, route, status |
localm_http_request_duration_seconds |
histogram | method, route |
localm_http_requests_in_flight |
gauge | none |
localm_prompt_tokens_total, localm_generated_tokens_total |
counter | none |
localm_time_to_first_token_seconds |
histogram | none |
localm_tokens_per_second |
histogram | none |
localm_inference_queue_depth |
gauge | none |
localm_models_loaded |
gauge | none |
localm_vram_used_bytes, localm_vram_total_bytes |
gauge | none |
route is the route template (/v1/models/{model_id}), never the requested
path; anything that is not a plain route is other. No metric carries a prompt,
a reply, a model name or a model path. The VRAM gauges come from the cached GPU
reading and are left out until a trustworthy one exists;
localm_inference_queue_depth counts requests waiting for a loaded model's
inference slot (not requests still waiting on a model that was just evicted).
A request that ends before any response is sent (the client left first) is
counted with status 499.
A reply the client abandons partway (a closed connection or a stop button) still
counts its prompt, the tokens generated before it stopped, and its time to first
token; a streamed request that leaves while still queued generated nothing and
counts nothing. Counters live in memory and start from zero at every server start.
Access: a server with an API key needs an admin key (the owner key from
localm key generate, or LOCALM_API_KEY). A server with no key answers only
on a loopback bind, and then only with the per-process shell token the local GUI
holds or this instance's local attach token, so a scraper on a keyless server
needs a key first. A cross-origin
browser request is refused.
scrape_configs:
- job_name: localm
authorization:
credentials: <admin key>
static_configs:
- targets: ["127.0.0.1:8642"]Scope: config:write.
Takes a JSON object. Recognised fields are description (alias message),
what_i_expected, what_happened, include_log and client. It saves the
report and never files it anywhere.
- At least one of
description/messageorwhat_happenedmust be non-empty, otherwise400.what_i_expectedon its own does NOT satisfy that check. - A body that sets
uploadis refused with400and nothing is saved: filing goes throughPOST /api/bug-report/sendwith a report the user has seen. - An absent or non-object body is a FastAPI validation
422, not the400. - A report that cannot be written to disk returns
500. A failed save is never reported as success. A save that takes too long returns504.
On success the response carries saved (true), filename, path (scrubbed of
machine paths for a caller other than the local operator), maintainer, and,
when the saved file can be read back, report_markdown (its full text, for
review and download) and report_sha256 (the hex SHA-256 of that text).
Scope: config:write.
Takes {"filename": ..., "sha256": ...}: a report saved by POST /api/bug-report, and the report_sha256 that route returned for it. The saved
file is filed as an issue through the configured proxy only when its current
text still has that digest, so what is filed is the text that was shown,
apart from its closing note about editing the report, which is left out. The
issue title is the report's own heading.
- A missing digest, or a filename that is not a bare saved-report name
(
bug-<stamp>.md), returns400. A file that cannot be read returns404. A file whose text no longer matches the digest returns409, and nothing is sent. - Past those checks the response is a
200carryingfilenameandmaintainer. A failed upload (including one that times out) hasuploaded: falseplusupload_stage,upload_messageandupload_error; the file stays saved. A rate-limited upload additionally setsrate_limited: trueandretry_afterin seconds. A successful upload setsuploaded: trueand, when the proxy returned one,issue_url.
The optional client object is reduced server-side before it is written into
the report, so a client cannot inflate a report with arbitrary content.
The bugreport_upload_* and update_* config keys that back these routes widen
a trust boundary and are owner-only; see the note under
GET /v1/config above.
These endpoints are present under localm serve too, not just localm gui.
They are scope-gated: GET /api/plugins requires PLUGINS_READ, and the
mutations require PLUGINS_ADMIN - except the install-deps event stream
below, which is a GET that also requires PLUGINS_ADMIN. A {name} that is
not a legal plugin id is a 404 on every {name} route.
Returns {plugins: [...], errors: {...}, suggest_plugins, auto_install_plugin_deps}.
Each plugin entry carries the state flags installed, enabled, active,
available, and loaded, plus name, version, description, scope,
builtin, protected, tab, label, icon, group, client_entry,
assets_base, requires, requires_extras, missing_deps,
missing_requires, tool_exports, commands, extra, and error.
Move a plugin between the available catalog and the installed set. Install
copies the plugin from the store into <data dir>/plugins/ and enables it;
uninstall removes the installed directory and accepts ?delete_data= to
also drop the plugin's stored data. They return {"status": "installed" | "uninstalled", "name"}. Uninstalling a protected plugin (chat) returns 409; an
unknown plugin returns 404; a failed install or uninstall returns 400; an
uninstall that disabled and unloaded the plugin but could not remove everything
it owns from disk returns 500.
Refresh plugin state from disk without a server restart. The bare route
refreshes every installed plugin and returns {"status": "ok" | "partial", "refreshed": [...], "failed": {name: error}}, with partial and the failed
plugins named in failed when some could not be refreshed; the {name} route
refreshes just that plugin and returns {"status": "refreshed"|"up-to-date", "name": name}. An unknown, uninstalled or non-builtin plugin returns 404; any
other refresh failure of the {name} route returns 500.
Toggle an installed plugin active or inactive in config. Disabling a
protected plugin (chat) returns 409; enabling a plugin that is not installed
returns 409; an unknown plugin returns 404 from enable; any other failure
to enable returns 400. They return {"status": "enabled" | "disabled", "name"}.
disable does not check whether a well-formed name is a real plugin - an
unknown name returns 200 the same as a real one.
PLUGINS_ADMIN scope, same as the routes above. Installs a third-party
plugin from a local directory ({"source": "/path/to/plugin"}, which must
contain a plugin.toml; force replaces an installed copy) - the HTTP sibling
of localm plugin install <dir>. The plugin loads on the next server start.
Returns {"status": "installed", "name", "version"}; a missing source, a
directory without a plugin.toml, or a rejected plugin is a 400.
PLUGINS_ADMIN scope. Installs a plugin's pip extras on this host (the HTTP
sibling of localm plugin install-deps NAME). The POST starts the install in the
background and returns {"status": "running"|"done"|"error", "name", "missing", "lines"}; an unknown plugin is a 404. Both routes are a 403 on a server bound
beyond loopback, because a remote client may never trigger a server-side pip.
The events route replays the whole log and then follows it as server-sent events:
data: {"type": "log", "line": "..."} per line, then data: {"type": "end", "ok", "installed", "failed", "error"}, with : keepalive comments between; it is a 404
when no install was ever started for that plugin.
Scope: keys:admin.
| Route | Purpose |
|---|---|
GET /v1/keys |
List every scoped key (metadata only, never the plaintext key). Returns {"keys": [{"id", "name", "scopes", "created", "expires", "last_used", "fs_access", "rag_roots"}], "is_owner", "presets", "owner_key_warning"}: whether the caller is the owner, the configured key presets, and owner_key_warning, a notice (or null) that the owner key file exists but holds no key, so the server runs keyless; it names the key file: the full path for the owner and open mode, a scrubbed one for a scoped key. |
POST /v1/keys |
Mint a new scoped key. Body: name, scopes (list of scope strings), fs_access (none or host), rag_roots (list of folder paths to confine RAG indexing to; omit for the key's normal, unconfined RAG reach), and either expires_in (seconds from now) or expires (epoch seconds); returns {"id", "name", "scopes", "expires", "fs_access", "rag_roots", "key"} (plus warnings when a granted scope unlocks a plugin that is not installed or not ready), with the plaintext key exactly once. A scopes that is not a list, an expires_in or expires that is not a number, a rag_roots that is not a list, or a scope that is neither a shipped scope nor a plain plugin-name identifier is a 400. Only an owner/admin caller may grant a privileged scope (admin, plugins:admin, keys:admin, config:write, coder:full), fs_access=host, or a non-empty rag_roots list - each is host-filesystem reach in some form, and a non-owner request for any of them gets 403. Minting the very first key from an open-mode loopback GUI also seeds an owner key and hands that browser a session cookie, so the local user is never locked out. 409 when the key store file exists but cannot be read as a JSON list; nothing is written. |
DELETE /v1/keys/{key_id} |
Revoke a key. Returns {"status": "revoked", "id"}; 404 if no such key exists, 409 when the key store file cannot be read as a JSON list. |
POST /api/auth/key/clear (scope config:write; CSRF-protected for
cookie-authenticated callers) deletes the server-side owner key and signs out
every session - the HTTP sibling of localm key clear and the GUI's clear
button (403 when a cookie-authenticated caller sends no valid CSRF token). It
does two things that can fail independently, and reports on both:
removing the stored credential, and revoking every browser session. Either one
surviving still grants access, so {"cleared": true, "warnings": []} is
returned ONLY when both completed. If either could not be finished, the
response is {"cleared": false, "warnings": [...]}, naming what survived: a
credential the server could not remove (a virus scanner or search indexer
holding the file open, for example), or "browser sessions (some devices may still be signed in)" when the session store could not be written. The labels
are deliberately path-free.
POST /api/auth/key/rotate (scope admin - not merely config:write, since
setting a caller-chosen key is privilege-equivalent to minting yourself owner)
rolls or sets the owner key - the HTTP sibling of localm key generate /
localm key set <key>. An empty or omitted body mints a fresh random key;
{"key": "<value>"} persists that exact one (an empty/whitespace key still
GENERATES, so this route can never drop the server to open mode - use
/api/auth/key/clear for that). Returns {"rotated", "active", "key", "warnings"}: key is the new key, shown once; active is whether the
server's next request will actually accept it (false when LOCALM_API_KEY in
the environment overrides the stored key, or on an unexplained write/read-back
mismatch, either surfaced in warnings). Existing browser sessions are NOT
revoked by a roll (sessions are decoupled from the key's value); the
compromise-recovery path that does revoke them is localm key recover, run
locally. A body that is not a JSON object, a key that is not a string, or a key
the store refuses (too short, or with characters an Authorization header cannot
carry) is a 400, and the previous key stays.
POST /api/session/logout reports the same way: it always clears the calling
browser's own cookie, and answers {"authed": false, "warnings": [...]} with a
warnings entry when the server-side session record could not be dropped (403
for a cookie-authenticated call without a valid CSRF token).
The two other session routes stay public. POST /api/session exchanges a key,
sent as {"key": "..."} or as a bearer token, for the HttpOnly localm_session
cookie and answers {"authed": true, "scopes": [...], "csrf": "..."}, where
csrf is the token to send as X-CSRF-Token on later changes; it is a 401 for
a key that does not verify and, once past the open-mode gate above, a 400 in open
mode, where there is nothing to log into. GET /api/session answers {"authed", "scopes", "required", "csrf", "session_mode"}: required is true when a key is configured (or require_auth
is on), csrf is empty unless the caller holds a cookie session, and
session_mode is the mode a chat from this caller is recorded under.
These routes exist only when the rag plugin is installed and enabled;
otherwise they 404. Scope: rag. See rag.md for the retrieval
design, indexing confinement, and CLI equivalents.
Across the collection routes: a name that is not a legal collection name is a
400, and an unknown collection a 404 (create excepted). The routes that read
or change an existing collection's content (detail, delete, query, reembed,
repair, remove-doc) answer 403 when the key's rag_roots confine it to
folders and the collection holds documents indexed from outside them; the
listing leaves such collections out. Create, delete and remove-doc answer 409
while another process is writing the collection.
A collection's stats are name, created, n_docs, n_missing, n_roots,
n_chunks, has_vectors, corrupt, chunks_bad_lines, vector_degrade_reason
and vector_dim.
| Route | Purpose |
|---|---|
GET /api/rag/collections |
List every collection with its stats (chunk/doc counts, has_vectors, dim_mismatch against whatever embedder is currently loaded). Returns {"collections": [...]}; dim_mismatch is null when it cannot be told. |
POST /api/rag/collections |
Create an empty collection. {"name": ...}; returns its stats; 409 if it already exists. |
GET /api/rag/collections/{name} |
One collection's stats plus docs (each {"path", ...}) and dim_mismatch. 404 for an unknown name. |
DELETE /api/rag/collections/{name} |
Delete a collection and its on-disk index. Returns {"status": "deleted", "name"}; 404 if it does not exist. |
POST /api/rag/collections/{name}/add |
Index paths into a collection. Always starts a background job and returns {"job_id": ...} immediately, on localm gui and a headless localm serve alike. Follow progress with GET /api/jobs/{id}/events (see jobs.md). Body: paths (1 to 50), embed (default true; degrades to lexical-only when no embedder is available), reindex (default false; re-index unchanged files). 400 for no paths, more than 50, a path the indexing policy denies outright, or one that does not exist; for a path outside the allowed folders the owner gets a 409 {"needs_consent": true, "reason": "outside_allowed", "addable": [...], "detail"} and any other key a 403; 503 when the server has no job registry. |
POST /api/rag/collections/{name}/upload |
Same, for uploaded files; same {"job_id": ...} response. Body: files (1 to 50 of {"filename", "content_b64"}), embed, reindex. 400 for no files, more than 50, or invalid base64; 413 for a file over 30 MB or a request over 100 MB. |
POST /api/rag/collections/{name}/query |
Semantic/lexical search against one collection; body query (empty is a 400), k (default 4, clamped to 1 to 20), relevant_only, rerank (null follows the rag_rerank setting), context; returns up to k hits; with "relevant_only": true it drops hits below the collection's relevance floor, so an unrelated query returns none; "context" (the user's previous message, cut to 600 characters) is searched together with a relevant_only query that refers back to the conversation, and the response's context_used says whether it was. The response is {"collection", "query", "hits", "relevant_only", "reranked", "rerank_model", "rerank_note", "context_used"}. Chunk text is defanged of control/frame tokens before it comes back, since it is untrusted content headed for a chat prompt. |
POST /api/rag/collections/{name}/reembed |
Recompute the collection's vectors with the currently-loaded embedding model, from the stored chunk text (no re-read of the source files). Job-backed ({"job_id": ...}); 400 when no embedding model is available. |
POST /api/rag/collections/{name}/repair |
Re-index every rebuildable document (mirrors localm rag repair). Upload-only documents cannot be rebuilt (the uploaded bytes are never retained) and are reported, not silently dropped. Returns {"needs_confirm": true, ...} instead of starting a job when repairing without embeddings would remove existing vectors; re-POST with confirm: true to proceed. Body: embed (default true), confirm. Returns {"job_id": ...} otherwise; 400 when the collection has no documents or every document was uploaded. |
POST /api/rag/collections/{name}/remove-doc |
Remove one indexed document ({"path": ...}) from a collection. Returns {"status": "removed", "path"}; 404 if it is not indexed there. |
POST /api/rag/extract |
Extract plain text from an uploaded chat attachment, entirely in memory (30 MB cap). Body: filename, content_b64, max_chars (omit for the whole text; otherwise clamped to 500 to 200000). Returns {"filename", "text", "chars", "truncated"}; 400 for invalid base64, 413 over 30 MB, 422 when no text can be extracted. Not used by collection indexing. The GUI's Chat and Coder attachments use POST /api/chat/extract instead: the same request and answer, served by the built-in chat plugin (chat scope), so it works without this plugin. |
GET /api/rag/rerank |
Whether Knowledge results are reranked. Returns {"enabled", "installed", "model", "candidates", "note"}: the rag_rerank setting, the registered reranker names, the reranker a query would use (null when none), how many hits it would see, and why a wanted rerank cannot run. Never loads a model. |
GET /api/rag/embedding |
Current embedding-model config and availability: {"model", "default", "internal", "installed", "dim", "error", "gpu_fallback_reason", "status", "can_download"}, with status one of ready, not_installed and unknown. Never loads a model; 504 if the embedder state cannot be read. A non-owner caller gets a withheld/generic answer for a model that is not one of localm's known internal ones or a registered model. |
POST /api/rag/embedding/download |
One-time download of the currently-configured embedding model, when the network policy would otherwise not fetch it automatically (net_mode: ask). Needs config:write - the same scope that governs net_mode itself. Writes nothing to config; net_mode: off refuses it with a 409 unless net_allow_model_downloads is on. Returns {"job_id", "model"}, or {"status": "already_installed", "model"} when the model is on disk; 403 without config:write, 409 when the configured model is not one of the internal downloadable ones. See network.md. |
POST /api/rag/embedding |
Select the embedding model (and install it, if it is one of localm's known internal models and not yet on disk). Two-step by confirm: without it, this is a dry run - no job starts, and the response is {"needs_confirm": true, "model", "collections", "note"} describing what switching would affect. Re-POST with confirm: true to actually switch; that starts a job and returns {"job_id": ...}. Changing the model requires an owner (admin-scoped) key (403 otherwise, for the dry run too); an empty model is a 400, and a second setup while one is running is a 409. |
These routes exist only when the memory plugin is installed and enabled;
otherwise they 404. Scope: memory. Memory is owner-scoped and off in
privacy mode: every write returns 403 in privacy mode (no new traces). See
memory.md. A write also answers 409 while another process holds the
namespace.
| Route | Purpose |
|---|---|
GET /api/memory |
The current facts (text + per-item metadata), pending correction proposals (see below), and whether writes are allowed. Returns {"text", "writable", "items": [{"id", "text", "importance", "source", "kind", "uses", "updated"}], "corrections": [{"id", "action", "proposed_text", "target_id", "target_text", "confidence", "created", "target_updated"}], "path", "can_download_embedder", "embedder_model"}; corrections is empty when writes are not allowed. |
PUT /api/memory |
Bulk edit. A line matching an existing fact keeps that record; new lines are added; omitted lines are deleted. Body {"text": ...}; returns {"status": "saved", "count"}. 413 past the 256-record or 64k-char cap. |
POST /api/memory/append |
Add one fact. Body {"text": ...}; returns {"status": "appended", "id"}; 400 for empty text, 413 at the record cap. |
PATCH /api/memory/{id} |
Edit one record's text or importance. Returns {"status": "saved", "item"}; 404 for an unknown id. |
DELETE /api/memory/{id} |
Delete one record. Returns {"status", "id"} with status deleted or absent; an unknown id is a 200 with absent. |
POST /api/memory/corrections/{id}/accept |
Apply a proposed correction (the auto-synthesis job spotted a new fact that contradicts a saved one): replaces or deletes the target record, archiving its old value. Returns {"status", "id", "target_id", "action"} with status updated, deleted or target_gone; 404 for an unknown correction, 500 when the old value could not be archived (nothing is applied). |
POST /api/memory/corrections/{id}/reject |
Discard a proposed correction, leaving the target record untouched. Returns the same object with status rejected or target_gone, and fails like accept. |
POST /api/memory/consolidate |
Distil durable facts from recent sessions now; 503 until a model is loaded. Returns {"status", "added", "updated", "deleted", "proposed", "pending", "episodic", "pruned", "facts"}, or {"status": "skipped", "reason": "no_sessions", "added": 0} when there is no session to read. |
GET /api/memory/forgotten |
Archived (forgotten) records available for recovery - read-only, available even in privacy mode. Returns {"items": [{"id", "text", "importance", "source", "kind", "forgotten_at"}]}. |
POST /api/memory/forgotten/{id}/restore |
Recover one archived record back into the live store; 404 if no matching archive entry exists. Returns {"status": "restored", "item"}. |
When the memory plugin recalls facts for a chat turn, POST /v1/chat/completions
returns an X-Localm-Memory response header: a compact JSON blob with the count
of injected facts, the items, and a degrade reason when semantic recall could not
be used (for example no_embedder before localm setup-embeddings).
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8642/v1", api_key="localm")
stream = client.chat.completions.create(
model="mymodel",
messages=[{"role": "user", "content": "Tell me a story."}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="", flush=True)- Concurrency: a GGUF model answers up to
parallel_slotsrequests at the same time (defaultauto: 4, or 1 while speculative drafting is on), decoding them together in one batch per step; further requests queue in order and stream awaitingstatus meanwhile. All of them share the model's one context window: a request that does not fit beside the running ones waits for them. A reply decoded beside others is not bit-identical to the same request decoded alone, even at temperature 0. A turn with an image runs on its own, after the replies already running. Other backends answer one request at a time per model. - Prompt cache: a GGUF model keeps the tokens of earlier requests in its
KV cache and computes only the part of a new prompt that they do not already
cover. llama.cpp's results can depend slightly on how many tokens it
computes in one step (it picks kernels by batch size, and with model layers
on the CPU and a GPU present it can run a large step on the GPU and a small
one on the CPU), so the same request can get a different reply depending on
the requests before it, even at temperature 0 or with a fixed
seed. Greedy output changes where the two most likely tokens are close. Sendcache_prompt: falsefor a reply that depends only on the request. - Speculative drafting: with
spec_sourceset tongram, the same request at temperature 0 can get a different reply from one server run to the next, and two identical requests in a row can differ. Withspec_sourceoffthat did not happen. Themtpanddraftsources have not been tested for this. - Context: the window starts at
n_ctxand grows on demand up ton_ctx_max(see the dynamic context window section of architecture.md). Conversations that outgrow the ceiling get a clear error instead of an OOM. - GUI-only routes: only a handful of
/api/models/*model-switching routes are actually gated behindlocalm gui. Everything else plugin-contributed - coder sessions (/api/coder/*), the Knowledge routes above, image/music/video generation, and the/api/plugins*management routes above - mounts the moment its plugin is enabled, on a headlesslocalm servetoo. See gui.md.