HTTP Server Spec
- Summary
- Scope
- Architecture
- Route Base Classes
- Route Folders
- Core Components
- Generated Docs
- Appendix A: PR History
- Appendix B: Error Format Cleanup
Summary
This spec defines how lemond's HTTP server is organized into route classes, core components and services, and how its API docs are generated from the routes.
The goal is that server PRs are narrowly scoped, easy to author, and easy to review:
- Narrow. Changing an endpoint edits one route file. Adding an endpoint adds one file and one registration line in its folder.
- Easy to author. Each route's folder matches its docs page, and its docs metadata sits in the same class as its handler.
- Easy to review. The base class enforces URL prefixes, auth and 405 handling, and every API change shows up as a diff in the generated docs.
Appendix A has the PR history these goals are drawn from.
Scope
This section lists what the design covers.
- Routes covered. Every HTTP route lemond serves, including the Ollama, Anthropic and MCP gateways.
- Not covered. The WebSocket APIs (
/realtime,/logs/stream) are not httplib routes, and their docs are hand-written. The MCP tool reference is generated bygen_mcp_boilerplate.py. - Tests. End-to-end integration tests cover the routes, and the drift check in Generated Docs covers the docs.
Compatibility with v2026.42 and Prior
This section lists what lemond keeps so that clients built against v2026.42 and earlier keep working.
- Public API. The design preserves the public API: URLs, auth, and request and response bodies. Most status codes and error shapes are preserved; however, improved argument validation can catch errors earlier and return a more accurate code.
- URL prefixes. Every endpoint that existed in v2026.42 or earlier is still served under
/api/v0/,/api/v1/,/v0/and/v1/, through theQuadpolicy in Prefixes. Only/v1/is documented from here on: docs, examples and new client code use/v1/, and the drift check fails a route whose text names one of the other three. - Older names. Argument aliases, such as
modelfor/delete'smodel_name, and path aliases, such asrerankingandrerankerforrerank, accept the names older clients send. Argument Validation defines how they are read. - Empty bodies. An
OptionalJsonroute reads an empty ornullbody as{}, because released CLIs send the literalnulltoPOST /unloadwhen no model is named. - Ollama clients. Ollama's Go client, which the
ollamaCLI uses, always sends bothmodelandnameon/api/show,/api/pulland/api/deleteand leaves one empty. The alias rule in Argument Validation drops the empty one, as Ollama'scmp.Or(model, name)does./api/chataccepts thethinkvalues Ollama accepts: a boolean or one of the levelslow,medium,highandmax.
Architecture
This section shows how a request reaches a route, and how the same routes produce the docs.
%%{init: {"flowchart": {"curve": "linear"}}}%%
flowchart TD
C(["Client"]) --> L["HttpListener"]
L --> M["RequestMiddleware<br/>origin check, auth by prefix"]
M --> R["RouteRegistry<br/>order, 405 stubs"]
R --> MR["ModelRoute subclass<br/>validate(), run()<br/><i>e.g. chat/completions,<br/>images/generations, audio/speech</i>"]
R --> A["ApiRoute subclass<br/>handle()<br/><i>e.g. load, pull,<br/>system-info</i>"]
MR --> ML["ModelLoader"]
MR --> SV["Services<br/>ModelJson, DownloadManager, JobOps"]
A --> SV
ML --> SUB["Subsystems<br/>Router, ModelManager, BackendManager,<br/>CloudProviderRegistry, RuntimeConfig"]
SV --> SUB
R -.->|"GET /internal/routes"| G["gen_api_boilerplate.py"]
G -.-> D["docs/api/*.md"]
Server is the composition root: it builds a ServerContext, registers every route folder with the RouteRegistry, and starts and stops the core components in order. A route writes only its handler; its base class does the shared work, described in Route Base Classes.
Route Base Classes
This section defines the two base classes every route extends, the RouteSpec each route declares, and the helpers routes share.
enum class Prefixes { Quad, Internal, Root }; // URLs and auth; see Prefixes below
enum class ArgIn { JsonBody, Query, Path, Form }; // where an argument comes from
enum class RequestFormat { Raw, Json, OptionalJson, Form }; // how the base class parses the request body
enum class ErrorFormat { Plain, OpenAi, Anthropic }; // the body shape of the base class's error responses
// Clients parse each kind of body differently, and some stream.
enum class ResponseFormat {
Json, // one JSON body
JsonLines, // newline-delimited JSON objects, streamed (application/x-ndjson)
EventStream, // server-sent events with JSON data, streamed (text/event-stream)
Text, // e.g. Prometheus metrics, markdown, a transcript
Binary, // e.g. audio, a glTF mesh
BinaryStream, // binary, streamed as generated, e.g. speech
Empty, // e.g. the 202 answering an MCP notification
};
// Lemonade honors some arguments only in part, such as images/generations' n, which accepts only 1.
// The docs badge each argument with its level.
enum class Support { Available, Partial, NotAvailable };
struct RouteArg {
std::string name;
ArgIn in;
nlohmann::json schema; // JSON-schema subset
bool required = false;
Support supported = Support::Available; // Partial: the description states the limit
std::string description;
std::vector<std::string> aliases; // other names clients send it under, e.g. "model" for "model_name"
bool forwarded = false; // lemond passes it to the backend without acting on it
};
struct RouteError {
int status; // e.g. 404
std::string code; // the code the body carries, e.g. "model_not_found"; empty when it carries none
std::string when; // the condition, e.g. "`id` names no job"
};
// Each format a route returns gets its own schema in the docs.
struct RouteResponse {
ResponseFormat format;
// JSON formats only. JSON Schema for the body, each NDJSON line, or each SSE event's data;
// --check validates against it. A format with several shapes, such as /load's three, uses oneOf.
nlohmann::json schema;
};
// Some examples require lemond to be in a certain state, for example unload requires a loaded
// model. A step sends a request that puts lemond in that state before the example runs.
struct ExampleStep {
std::string route; // RouteSpec::id
std::string example; // that route's RouteExample::name; runs after its own setup
nlohmann::json args; // set instead of example: a request sent as is and left out of the docs
};
// gen_api_boilerplate.py sends each example to lemond and records the response into the docs, and
// --check validates the response against the schema of the example's format.
struct RouteExample {
std::string name; // subsection heading, e.g. "streaming" or "save"; required when a route has several
std::string description; // what this example shows that the route's other text does not; opens its subsection
ResponseFormat format; // the RouteResponse the response is validated against
nlohmann::json args; // argument values, e.g. {"id": "$lemonade.jobs_create/id"}
std::vector<ExampleStep> setup; // run first, in order
};
struct RouteSpec {
std::string id; // "<page>.<name>", e.g. "openai.chat_completions"; docs anchor
std::vector<std::string> methods; // e.g. {"POST"}
std::vector<std::string> paths; // without prefix; extra entries are aliases, e.g. rerank, reranking
Prefixes prefixes = Prefixes::Quad;
std::string summary; // one line in the page's summary table
std::string description; // reference text for the route's docs section
std::vector<std::string> notes;
bool experimental = false; // its contract may still change, so the docs badge it experimental
std::vector<RouteArg> args; // every argument
std::vector<RouteResponse> responses; // one per format; empty for an unsupported route, e.g. Ollama's POST /api/create (501)
std::vector<RouteExample> examples; // the first is the route's main example
std::vector<RouteError> errors; // every error response, including those its base class writes
ErrorFormat error_format = ErrorFormat::Plain;
RequestFormat request_format = RequestFormat::Raw; // Raw: no parsing or argument checks; OptionalJson: an empty or null body is {}
bool quiet_log = false; // leave out of the access log
};
class ApiRoute {
public:
explicit ApiRoute(ServerContext& ctx) : ctx_(ctx) {}
virtual ~ApiRoute() = default;
virtual RouteSpec spec() const = 0;
virtual void handle(RouteRequest& req, httplib::Response& res) = 0;
protected:
ServerContext& ctx_;
};
class ModelRoute : public ApiRoute {
public:
using ApiRoute::ApiRoute;
void handle(RouteRequest& req, httplib::Response& res) final; // resolve, validate, load, run
protected:
virtual const RouteArg& model_arg() const = 0; // the argument that names the model
virtual bool validate(RouteRequest& req, httplib::Response& res) { return true; }
virtual void run(RouteRequest& req, httplib::Response& res) = 0;
};
Prefixes
The prefix policy sets a route's URLs and the key its requests need, enforced in Server::authenticate_request():
| Policy | URLs | Auth |
|---|---|---|
Quad |
/api/v0/, /api/v1/, /v0/ and /v1/ plus each path; only /v1/ is documented (see Compatibility). A POST-only path also gets a GET route that answers 405 |
LEMONADE_API_KEY |
Internal |
/internal/ plus each path |
LEMONADE_ADMIN_API_KEY |
Root |
Each path as written, such as /live, /metrics, /mcp and Ollama's /api/* |
By path: /metrics, /mcp and /api/* need LEMONADE_API_KEY; /live needs none |
Argument Validation
This section defines how ApiRoute::serve() checks a request against the route's RouteArgs before it calls handle(), so a handler never receives a value its schema does not allow.
- Checks. For a
JsonorOptionalJsonroute,serve()checks everyJsonBodyandQueryargument, and for aFormroute,ModelRoutechecks everyFormargument once it has builtreq.body. Arequiredargument or file must be present, and a value must match its schema'stype,enum,const,minimum,maximum,minLength,maxLengthandpattern, as must each value nested in it underitems,propertiesoradditionalProperties. Anullcounts as absent, andserve()removes it unless the schema'stypeincludesnull.integeraccepts an integral number such as2048.0, and keys that noRouteArgdeclares are ignored. A body that is not a JSON object fails askBodyNotJson.Rawroutes, such as MCP's JSON-RPC endpoint, are not checked. - Aliases. A client may send an argument under any of its
aliases. An empty string is not a value when another spelling has one, andserve()removes it.serve()renames an alias to the argument's name, so a handler reads only the name, and the name wins when a request sends both. The docs list the aliases beside the name. - Forwarded arguments. lemond passes some arguments to the backend without acting on them, such as chat/completions'
temperature. These are markedforwardedand are not checked, since the backend decides what it accepts. - Reading arguments. Each argument is a
RouteArgconstant beside its handler, andspec()lists it. Route code names an argument only through its constant:req.has(),req.arg()andreq.value()read it,req.file()reads an uploaded file, andreq.body[kStream.name]changes a value the route forwards. AQueryorPathvalue is converted to its schema's type. A shared helper receives the values of the arguments it needs, not the request. A helper that uses the whole body, such as one that forwards it to a backend or saves it as a model definition, receives the body. An argument that several routes take alike is one constant inshared_args.hthat they all list, such asgeneration::kTemperature. Routes take an argument alike when everything about it matches except how its description is worded, and they treat it the same way. When routes treat such an argument differently, such as Anthropic'smessages, which lemond converts to OpenAI's, and chat/completions'messages, which it forwards, each route keeps its own constant, and the generator'sDISTINCT_ARGUMENTSlists the group with how the routes differ. A route that builds another route's request, as the gateways do, names that route's arguments through its constants. - Error responses. The three failures are
ApiRoute::kBodyNotJson,kArgMissingandkArgInvalid. Their codes areinvalid_json,missing_argumentandinvalid_argument, and they are written in the route'serror_format:
ErrorFormat |
Body |
|---|---|
Plain |
{"error": "message", "code": "missing_argument"} |
OpenAi |
{"error": {"message", "type": "invalid_request_error", "code"}} |
Anthropic |
{"type": "error", "error": {"type": "invalid_request_error", "message", "code"}} |
const RouteArg kModelName{"model_name", ArgIn::JsonBody, {{"type", "string"}}, true, Support::Available,
"The model to delete.", {"model"}};
const RouteArg kTemperature{"temperature", ArgIn::JsonBody, {{"type", "number"}}, false, Support::Available,
"Sampling temperature.", {}, true};
s.args = {kModelName}; // in spec()
const std::string model_name = req.value(kModelName, ""); // in handle()
ModelRoute
ModelRoute is the base for routes that run a loaded model: chat/completions, images/generations, etc. Its handle() is final and runs these steps:
- Builds
req.body. For aRequestFormat::Formroute, it copies eachArgIn::Formtext field intoreq.body, converted to the arg's schema type, answers 400 when the request is notmultipart/form-data, and checks the arguments as Argument Validation describes. File parts stay on the httplib request. Registration rejects aModelRoutewhoserequest_formatisRaw, since step 2 reads the model fromreq.body. - Resolves the model name. It reads the argument
model_arg()names and passes the value throughcanonical_model_name(). - Calls
validate(). Returning false meansvalidate()already wrote the response. - Requires a model. With no
modelin the request, it answers 400. - Auto-loads the model through
ModelLoader, unlessvalidate()setreq.model_already_loadedbecause it picked a model that is already loaded; loading it again would demote a routing helper to a standard model. On failure it writes thecreate_model_errorresponse, the same shape for every model route. - Calls
run().
validate() runs before any load, so it can reject a bad request without loading a model (speech's model-type check, 3D's image check), attach uploaded files in the shape its backend expects (transcription's raw file, images/edits' base64 image and mask), fill in a missing model (classify picks the single loaded classifier), rewrite the model (router collections), or answer the request itself (Omni collections, which load their own components).
Every other route extends ApiRoute directly, including images/upscale, which runs its upscaler as a one-shot CLI through IUpscaleServer::upscale_via_cli() and never loads it.
Request and Context
RouteRequest holds one request's data: the httplib request, the parsed request body unless request_format is Raw, the Query and Path arguments, the resolved model name, and the router decision when there is one. Its has(), arg(), value() and file() read an argument through its constant, as Argument Validation describes.
ServerContext holds pointers to the subsystems (Router, ModelManager, BackendManager, CloudProviderRegistry, AliasManager, RuntimeConfig), to HttpListener, and to the services in Core Components. It holds no state of its own. Everything it points to lives as long as lemond, so it has no WebSocketServer pointer: a host or websocket_port change replaces that server, and /health reads its port from HttpListener::websocket_port().
Route objects are built once at startup and serve concurrent requests, so they hold no per-request state (Invariant #7).
Shared Helpers
Routes call these helpers from their own code, so the flow stays visible in the route file:
| Helper | Purpose |
|---|---|
ApiRoute::stream_response() |
The only way a route streams. Wraps httplib's chunked content provider, sets the SSE headers, and carries the cancel token and telemetry span into the stream, since they must outlive handle() |
cancel_on_disconnect() |
Aborts the upstream backend request when the client disconnects during a non-streaming request (Invariant #10) |
router_dispatch() |
Runs collection.router policies and returns the decision; lives in routes/router/ |
attach_route_decision() |
Adds the router decision to the response body and headers |
record_usage() |
Records token usage and timings for telemetry |
set_error_response() |
Maps a backend error body to its HTTP status, or to the given RouteError's status when the body has none |
canonical_model_name() |
Returns the name the registry knows for a model name a client sent, after stripping a :latest tag, unless the tagged name is itself a registered model or alias, and following model aliases |
Response Schemas
This section defines where the schemas in RouteResponse come from:
- In the route class. Response schemas are JSON Schema, written next to the route's
RouteArgschemas, so a route's whole contract sits beside its handler.gen_api_boilerplate.pyreads them fromGET /internal/routeswith the rest of theRouteSpec. - Shared shapes. A shape that more than one route returns has a schema function declared beside the function that builds it, such as
ModelJson::schema()besideModelJson::to_json(), so the shape and its schema change together. - Pass-through routes. The schema describes the public contract the route follows, such as OpenAI's chat completion object, limited to the fields Lemonade returns.
The routing decision is the exception to shared shapes: the routing engine owns its contract in decision.schema.json, and lemond does not read that file. A route that returns the decision describes it with a short inline object whose description links to the router page, and the router page links to decision.schema.json for the full shape:
schema["properties"]["x_lemonade_route"] = {
{"type", "object"},
{"description",
"The routing decision, when `model` names a `collection.router` model. See "
"[Routing](./router.md#routing-collectionrouter) for the decision object."},
};
Errors
This section defines how a route declares the error responses it can answer with, such as a 404 for an unknown job id. Each is a RouteError: its HTTP status, the code its body carries, and when it is returned. The route's handler writes some of a route's error responses, and its base class and shared helpers write the rest, so RouteSpec::errors lists them from these places:
- In the route class. Each error response a route writes is a
RouteErrorconstant beside its handler. The handler passes it to an error writer such aswrite_plain_error(), which takes the status and code from it, andspec()lists it. - Shared error responses. An error response that more than one route writes is declared beside the function that writes it, such as
ModelLoader::errors()besideModelLoader::write_load_error(). - Base classes. A base class declares the error responses its own steps write, such as
ApiRoute::kArgMissingfor a missing required argument, and a route lists the ones its base class writes for it. A route that overrideswrite_exception()lists what its override writes instead. - Backend errors. A route that passes a backend's error through lists
kBackendError, and also the backend error responses a client can act on, such as llama.cpp's 501 for a model that cannot embed.
An error in a response that has already started, such as an error event in a stream, is a shape of the route's RouteResponse::schema.
const RouteError kUnknownJob{404, "", "`id` names no job"};
s.errors = {kUnknownJob}; // in spec()
write_plain_error(res, kUnknownJob, "unknown job"); // in handle()
Complete Example
routes/openai/text/chat_completions.cpp:
nlohmann::json chat_completion_schema() {
auto schema = nlohmann::json::parse(R"({
"type": "object",
"required": ["id", "object", "created", "model", "choices", "usage"],
"properties": {
"id": {"type": "string"},
"object": {"const": "chat.completion"},
"created": {"type": "integer"},
"model": {"type": "string"},
"choices": {"type": "array", "items": {
"type": "object",
"required": ["index", "message", "finish_reason"],
"properties": {
"index": {"type": "integer"},
"message": {
"type": "object",
"required": ["role", "content"],
"properties": {
"role": {"const": "assistant"},
"content": {"type": ["string", "null"]}
}
},
"finish_reason": {"type": "string"}
}
}},
"usage": {
"type": "object",
"required": ["prompt_tokens", "completion_tokens", "total_tokens"],
"properties": {
"prompt_tokens": {"type": "integer"},
"completion_tokens": {"type": "integer"},
"total_tokens": {"type": "integer"}
}
}
}
})");
// Router collections add their routing decision to the response.
schema["properties"]["x_lemonade_route"] = {
{"type", "object"},
{"description",
"The routing decision, when `model` names a `collection.router` model. See "
"[Routing](./router.md#routing-collectionrouter) for the decision object."},
};
return schema;
}
nlohmann::json chat_completion_chunk_schema() {
return nlohmann::json::parse(R"({
"type": "object",
"required": ["id", "object", "created", "model", "choices"],
"properties": {
"id": {"type": "string"},
"object": {"const": "chat.completion.chunk"},
"created": {"type": "integer"},
"model": {"type": "string"},
"choices": {"type": "array", "items": {
"type": "object",
"required": ["index", "delta", "finish_reason"],
"properties": {
"index": {"type": "integer"},
"delta": {
"type": "object",
"properties": {
"role": {"const": "assistant"},
"content": {"type": ["string", "null"]}
}
},
"finish_reason": {"type": ["string", "null"]}
}
}}
}
})");
}
const RouteArg kModel{"model", ArgIn::JsonBody, {{"type", "string"}}, true, Support::Available,
"Model to run; loaded on first use."};
const RouteArg kMessages{"messages", ArgIn::JsonBody, {{"type", "array"}}, true, Support::Available,
"Conversation in OpenAI format."};
const RouteArg kLogprobs{"logprobs", ArgIn::JsonBody, {{"type", "boolean"}}, false, Support::NotAvailable,
"Return the log probability of each output token.", {}, true};
class ChatCompletionsRoute : public ModelRoute {
public:
using ModelRoute::ModelRoute;
RouteSpec spec() const override {
RouteSpec s;
s.id = "openai.chat_completions";
s.methods = {"POST"};
s.paths = {"chat/completions"};
s.prefixes = Prefixes::Quad;
s.summary = "Chat Completions";
s.description = "Generates the next assistant message for a conversation.";
s.args = {kModel, kMessages, common::kStream, kLogprobs};
s.responses = {
{ResponseFormat::Json, chat_completion_schema()},
{ResponseFormat::EventStream, chat_completion_chunk_schema()},
};
RouteExample completion;
completion.name = "non-streaming";
completion.format = ResponseFormat::Json;
completion.args = nlohmann::json::parse(R"({
"model": "Qwen3-0.6B-GGUF",
"messages": [{"role": "user", "content": "What is the capital of France?"}]
})");
RouteExample stream;
stream.name = "streaming";
stream.format = ResponseFormat::EventStream;
stream.args = nlohmann::json::parse(R"({
"model": "Qwen3-0.6B-GGUF",
"messages": [{"role": "user", "content": "What is the capital of France?"}],
"stream": true
})");
s.examples = {completion, stream};
s.errors = join_errors({{kBodyNotJson, kArgMissing, kArgInvalid, kBackendError, kUnexpectedException},
ModelLoader::errors()});
s.error_format = ErrorFormat::OpenAi;
s.request_format = RequestFormat::Json;
return s;
}
protected:
const RouteArg& model_arg() const override { return kModel; }
bool validate(RouteRequest& req, httplib::Response& res) override {
if (is_omni_collection(ctx_, req.model)) {
run_omni_collection(ctx_, req, res);
return false;
}
req.route_decision = router_dispatch(ctx_, req);
return true;
}
void run(RouteRequest& req, httplib::Response& res) override {
if (req.value(common::kStream, false)) {
stream_response(req, res, [this](const std::string& body, httplib::DataSink& sink) {
ctx_.router->chat_completion_stream(body, sink);
});
return;
}
auto cancel = cancel_on_disconnect(req);
nlohmann::json response = ctx_.router->chat_completion(req.body);
if (response.contains("error")) {
set_error_response(response, res, kBackendError);
return;
}
attach_route_decision(response, res, req.route_decision);
record_usage(response, req);
res.set_content(response.dump(), "application/json");
}
};
std::unique_ptr<ApiRoute> make_chat_completions_route(ServerContext& ctx) {
return std::make_unique<ChatCompletionsRoute>(ctx);
}
The folder's routes/openai/text/routes.cpp registers it with one line: registry.add(make_chat_completions_route(ctx));.
Route Folders
This section lays out where each route lives. Each top-level folder under routes/ is one docs/api page, and each subfolder is a section of that page.
src/cpp/server/
server.cpp Server: builds ServerContext, registers folders, starts and stops core
core/ HttpListener, RequestMiddleware, RouteRegistry, ApiRoute, ModelRoute,
ConfigEffects, WebUi
services/ ModelLoader, ModelJson, DownloadManager, JobOps, audio format negotiation,
gateway stream conversion, Omni collection orchestration
routes/
openai/ docs/api/openai.md
text/ chat/completions, completions, responses, embeddings
images/ images/generations, images/edits, images/variations, images/upscale
audio/ audio/transcriptions, audio/speech
models/ models, models/{id}, and the Lemonade extensions
models/{id}/files and models/{id}/options
lemonade/ docs/api/lemonade.md
models/ models/register, pull, pull/variants, registry/search, delete,
models/check-updates
downloads/ downloads, downloads/control
load/ load, unload
inference/ classify, audio/generations, 3d/generations
backends/ install, install/dry-run, uninstall, cloud/auth
system/ health, live, stats, system-stats, system-info, metrics, docs
jobs/ jobs and its sub-routes
llamacpp/ docs/api/llamacpp.md: rerank, slots, tokenize
router/ docs/api/router.md: routing/validate, router_dispatch()
internal/ docs/api/internal.md: shutdown, telemetry/flush, pin, set, config,
config/defaults, cleanup-cache, simulate-vram-pressure, models/sync,
models/sync/status, aliases, routes
ollama/ docs/api/ollama.md
anthropic/ docs/api/anthropic.md
mcp/ docs/api/mcp.md
Registration follows these rules:
- One file per route. A route is one
.cppfile that defines its class and amake_<name>_route()factory. Headers for core components and services go insrc/cpp/include/lemon/server/; route classes have no header. The arguments of the OpenAI routes, whose requests the gateways build, are declared inopenai_args.hthere and defined inroutes/openai/. Arguments that several routes take alike are declared inshared_args.hthere and defined inroutes/shared_args.cpp. - One list per folder. Each folder's
routes.cppdeclares its factories and adds them to theRouteRegistryin registration order.Servercalls each folder's registration function. CMake collectsroutes/sources withfile(GLOB_RECURSE ... CONFIGURE_DEPENDS), as it does fordocs/api, so adding a route edits no CMake file. - Order. httplib uses the first registered route that matches, so routes register in the order each folder's
routes.cpplists them, and a folder lists a specific path before a pattern that also matches it, such asmodels/{id}/filesbeforemodels/{id}.WebUiregisters its catch-all route after every API route. - Path parameters. Each
{name}in a path becomes a capture group that matches itsPathargument'spattern, digits for aninteger, or one path segment otherwise.RouteRequestreads path parameters by group number, so registration rejects a route whose paths name different parameters or name them in a different order, and apatternthat has a group of its own. - Gateways. The Anthropic routes live in
routes/anthropic/, separate from Ollama's. The OpenAI-to-gateway stream conversion both use lives inservices/.McpServer's HTTP route is a route class inroutes/mcp/.
Core Components
This section lists the core components, each with an interface of a few calls:
| Class | Interface | Responsibilities |
|---|---|---|
HttpListener |
start(), stop(), rebind(), websocket_port() |
Thread pools, IPv4 and IPv6 bind loop, UDP beacon, the WebSocketServer and its restart, rebind on host or port change |
RequestMiddleware |
One pre-routing function | Origin check and CORS, API and admin keys, per-request telemetry and session context |
RouteRegistry |
add(), apply(httplib::Server&), list() |
Prefix expansion, 405 stubs, registration order, the route list for the docs |
ConfigEffects |
apply(key), called at startup and on every config change |
Applies each config key's side effects, at startup and on change |
WebUi |
register_routes(httplib::Server&), called last |
The SPA, the legacy status page, static assets and the SPA fallback |
ModelLoader |
ensure_loaded(), write_load_error(), errors() |
Auto-load, collection loading, load errors. Used by ModelRoute and the gateways |
ModelJson |
to_json(id, info), schema() |
Builds the model JSON for the models routes, the status page and the job engine |
DownloadManager |
start(), list(), control(), cancel_all(), stream(), job_schema(), event_schema() |
Server-owned download jobs and their threads, and the download event stream that pull and install send |
JobOps |
build(ServerContext&) |
Builds the job engine's op providers |
These are internal details of other classes:
resolve_host_to_ip()andparse_traceparent()are private toHttpListenerandRequestMiddleware./internal/shutdownis an ordinary route inroutes/internal/that callsServer::stop().
Generated Docs
This section specifies how docs/api is generated from the routes, including real request and response examples.
Generator
docs/tools/gen_api_boilerplate.py follows gen_mcp_boilerplate.py: it starts lemond through the Lemond harness in gen_backend_boilerplate.py and rewrites only BEGIN/END GENERATED regions. It runs these steps:
- Reads every
RouteSpecfromGET /internal/routes, a route class inroutes/internal/. - For each page (a top-level folder), writes the summary table at the top of the page.
- For each route, writes a region in the API reference layout of the documentation guide: the method and path heading, a status badge, the description, the route's other paths and its auth, a Parameters table with each argument's
Supportbadge (available,partialornot_available), the notes, the Examples and Response format sections described in Examples, and an Errors table listing each of the route'serrorsby status, code andwhen. The route's badge is the first that applies:experimentalwhen the route setsexperimental,not_availablewhenresponsesis empty,fully_availablewhen every argument isSupport::Available, andpartially_availableotherwise. - Fills the Examples section from the response cache, running each example's setup and request against lemond only when its cache entry is invalid.
Page intros and concept sections stay hand-written between the regions, such as routing policies, VAD configuration, model labels, and the WebSocket APIs.
Examples
The Examples section gives each of the route's examples its own subsection, headed by its name, such as "Streaming". The subsection opens with the example's description, then has PowerShell and Bash tabs for the request and shows what lemond returned. A route with one example has a single Example section with no subsection. The Response format section then describes each format once, under a heading per format when the route has several. It opens with the top-level description of the format's RouteResponse::schema, then lists each property's description, with nested properties indented under their parent. A format with several shapes gives each shape a heading from its title, such as "Error event", then that shape's description and properties. A schema shared between routes, such as the routing decision, is listed in full wherever it appears. The generator builds and records examples by these rules:
- Arguments. An example's
argsmaps argument names to values, and each value goes where itsRouteArg::insays: the path, the query string, the JSON body or the multipart form. - Files. A
@fixtures/<file>marker is read fromdocs/tools/fixtures/. It is a whole value, or follows a prefix such asdata:image/png;base64,. A JSON body argument shows it in curl as$(base64 -w0 <file>), and a form argument as-F <name>=@<file>. - Setup. The steps in
setuprun first, in order. A step that names an example runs it after that example's own setup; a step withargssends that request as is and is left out of the docs. A$<route id>/<json pointer>value takes that field from the most recent response the route returned. - One lemond. Every example runs against one lemond started by the
Lemondharness, which starts it again after an example stops it, as/internal/shutdowndoes. An example that deletes or uninstalls something removes what its own setup added. - Recorded output. Every response opens with its status and reason phrase on its Response label, such as
200 OK, followed by a JSON body, the first three and last two events or lines of a stream, the first 20 lines of aTextbody, or theContent-Typeand byte count of aBinaryorBinaryStreambody. - Long values. A string longer than 1,000 characters in a JSON body, NDJSON line or SSE event, such as a base64 image, is recorded as its first 80 characters and its length. In the page, a JSON array of more than 16 numbers or strings that does not fit on one line, such as an embedding, shows its first 8 items and how many more there are. A JSON array of objects, or an object whose keys are data rather than field names (a schema with
additionalProperties), such as search results or system-info's recipes, shows its first entry and how many more there are. - Paths. Recorded responses show the harness's temporary cache directory as lemond's default,
~/.cache/lemonade, and the recording machine's home directory as~.
Route Documentation Contract
This section specifies what each route's text says. The generator prints each route's heading, status badge, URL, aliases, auth requirement, Parameters table and recorded examples, so no text field restates them. A Quad route is documented only at its /v1/ URL; the other prefixes stay undocumented and serve existing clients.
| Field | States | Never states |
|---|---|---|
summary |
What the route does, as a verb phrase of at most 10 words, such as "Pause a job" | Support status, formats, the description |
description |
What the route does, and who uses it when that is not obvious, in at most 3 sentences, third person present ("Loads a model...") | URLs, prefixes, aliases, auth, argument details or defaults, the page intro |
notes |
Behavior no argument, field or error can carry: side effects, interactions between arguments, or a concept that belongs to this route alone, under a ### heading. One topic per note |
The description, another note, a parameter row, a field description, a request or response, which belongs in examples |
RouteArg::description |
What the argument controls, its accepted values, its default ("Defaults to false."), and when it is required if that depends on other arguments |
Where the argument goes, the Required column, the route's behavior |
Top-level description of a RouteResponse::schema, or of one of its shapes |
What the response is as a whole: how a stream's lines or events relate, such as which line ends it, or when this shape is returned | A field's meaning |
title of a shape |
A short name for the shape, such as "Chunk" or "Error event" | The format, when the shape is returned |
Property description in a RouteResponse::schema |
What the field means, and when it is present if it is not always | Its type, the route's behavior |
RouteError::when |
The condition that produces the error, as a clause such as "id names no job". For an error passed through from a backend, also that the backend's status replaces this one |
The status, the code, the body's message |
RouteExample::name |
A lowercase label for what differs, such as streaming or save |
Formats the name already implies |
RouteExample::description |
What this example shows that the route's other text does not, such as which model or file it uses, or a request shape that only this example uses | The route's general behavior, which belongs in notes |
These rules apply across the fields:
- Each fact appears once in a route's section; other text refers to it.
- A behavior claim matches the handler code, or a run of the route, before it is written.
- A URL uses the
/v1/form, and never/api/v0/,/api/v1/or/v0/. - Identifiers, values and placeholders such as
<PROVIDER>are in backticks. - A mention of another route links to its section.
A page intro states what the API family is and the facts true of every route on the page, such as Ollama's error shape, and routes do not repeat them. Routes state only whether they need an API key, so the top-level API reference states how a client sends LEMONADE_API_KEY and LEMONADE_ADMIN_API_KEY. A page sets the order of its sections: the generator never moves an existing region, inserts a new route's region after the route registered before it, and orders the summary table to match the sections.
Response Cache
Recorded responses live in docs/tools/api_examples_cache.json, one entry per example. An entry's key is the SHA-256 of:
- the route
idand the example'sformat - the canonical JSON of its
argsand of every step in itssetup - for a pass-through route, its backend's pinned version from
backend_versions.json
A new example, a changed request, or a backend version bump changes the key and invalidates the entry. A schema change does not, because Drift Check validates every entry against the current schemas. Valid entries are never re-run, so examples change only when one of those inputs does. The cache is first warmed on a machine that runs every backend.
A recorded response shows the shape of the route's response. Its values can fall behind what lemond returns today, for example after a model's weights change, and that is fine: Drift Check holds the shape, not the values.
Drift Check
CI runs gen_api_boilerplate.py --check on a hosted runner, beside the other generators, without loading any model. It fails when:
- a route has no region, or a region has no route
- a page that has routes has no entry in the
mkdocs.ymlnav or no row in the page table indocs/api/README.md, or either one lists a page underdocs/api/that has no routes, other thanREADME.mditself - any generated text is stale
- any cache entry is invalid
- a recorded response does not match its entry's format, or a JSON body, NDJSON line or SSE event's data does not match its schema, validated with
jsonschema(OpenAI's closing[DONE]event is skipped) - a JSON format has no
schema, or another format has one, or a shape in aoneOfhas notitle - a file under
routes/orservices/assigns a literal error status tores.statusor passes one to an error writer, or a route file uses aRouteErrorthat its route'serrorsdoes not list, or leaves out one its base class writes for it or lists one its base class never writes for it - a route file declares an argument in
argsinstead of as aRouteArgconstant, names an argument or one of its aliases by a string literal, such asreq.body["stream"], writes another route's request with a string literal key, or uses aRouteArgthat its route'sargsdoes not list - a helper under
routes/orservices/that takes aRouteRequestnames a declared argument by a string literal on it, such asreq.body["model"] - two
RouteArgconstants have the same name and aliases, location, schema and flags, whatever their descriptions say, unless the generator'sDISTINCT_ARGUMENTSlists them with how their routes treat the argument differently, or that list names a group that no longer matches - a route's
description,notesor argument descriptions name an error status, such as404 - a
Rawroute other than MCP's JSON-RPC endpoint reads its request body - a route's text has an em dash, a placeholder outside backticks, the prefixes
/api/v0/,/api/v1/or/v0/, a phrase the generator prints ("Also served at", "RequiresLEMONADE_API_KEY") or a> **Note:**callout; asummaryruns over 10 words; adescriptionruns over 3 sentences; or a sentence of 40 or more characters appears twice in one route's region - a JSON format, or one of its shapes, has no example whose response it validates, unless the shape sets
x-no-exampleto the reason no recording machine can return it - a field in a recorded JSON response has no
descriptionin its schema, unless an enclosing field is described without listing its properties - a recorded JSON body runs over 100 lines in the page
- an example names an argument the route does not have or a format not in
responses, a route with several examples leaves one unnamed or repeats a name, asetupstep names a route or example that does not exist, or a$<route id>value names a route that did not run during setup
The author fixes it by running the generator locally and committing the result. The weekly backend-bump workflows (validate_llamacpp.yml, validate_sdcpp.yml, validate_vllm.yml) run each new version on self-hosted runners; their runner job also runs the generator for the entries the bump invalidated, and the PR the workflow opens includes the updated cache.
Claim Review
Drift Check cannot tell whether a sentence is true, so rule 2 of the Route Documentation Contract is enforced by review. When a PR changes a route's text, schemas, examples or handler, the reviewer reads the route's generated section beside its handler and checks:
- each claim in the description, notes, argument descriptions and field descriptions against the code
- each example's name and description against its request
- that behavior a client relies on, such as an error status or a side effect, is stated
A claim that cannot be traced to the code or to a recorded response is fixed or removed before the PR merges.
Generated Output
The route in Complete Example generates this region of openai.md:
<!-- BEGIN GENERATED: openai.chat_completions -->
## `POST /v1/chat/completions`
<sub></sub>
Generates the next assistant message for a conversation.
Requires `LEMONADE_API_KEY` when it is set.
### Parameters
| Parameter | Required | Description | Status |
|-----------|----------|-------------|--------|
| `model` | Yes | Model to run; loaded on first use. | <sub></sub> |
| `messages` | Yes | Conversation in OpenAI format. | <sub></sub> |
| `stream` | No | Stream tokens as server-sent events. | <sub></sub> |
| `logprobs` | No | Return the log probability of each output token. | <sub></sub> |
### Examples
#### Non-streaming
=== "PowerShell"
```powershell
Invoke-WebRequest `
-Uri "http://localhost:13305/v1/chat/completions" `
-Method POST `
-Headers @{ "Content-Type" = "application/json" } `
-Body '{"model": "Qwen3-0.6B-GGUF", "messages": [{"role": "user", "content": "What is the capital of France?"}]}'
```
=== "Bash"
```bash
curl http://localhost:13305/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "Qwen3-0.6B-GGUF", "messages": [{"role": "user", "content": "What is the capital of France?"}]}'
```
**Response:** `200 OK`
```json
{
"id": "chatcmpl-a1B2c3D4e5F6",
"object": "chat.completion",
"created": 1759766400,
"model": "Qwen3-0.6B-GGUF",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "The capital of France is Paris."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 15, "completion_tokens": 8, "total_tokens": 23}
}
```
#### Streaming
=== "PowerShell"
```powershell
Invoke-WebRequest `
-Uri "http://localhost:13305/v1/chat/completions" `
-Method POST `
-Headers @{ "Content-Type" = "application/json" } `
-Body '{"model": "Qwen3-0.6B-GGUF", "messages": [{"role": "user", "content": "What is the capital of France?"}], "stream": true}'
```
=== "Bash"
```bash
curl http://localhost:13305/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "Qwen3-0.6B-GGUF", "messages": [{"role": "user", "content": "What is the capital of France?"}], "stream": true}'
```
**Response:** `200 OK`
```text
data: {"id": "chatcmpl-a1B2c3D4e5F6", "object": "chat.completion.chunk", "created": 1759766400, "model": "Qwen3-0.6B-GGUF", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}
data: {"id": "chatcmpl-a1B2c3D4e5F6", "object": "chat.completion.chunk", "created": 1759766400, "model": "Qwen3-0.6B-GGUF", "choices": [{"index": 0, "delta": {"content": "The"}, "finish_reason": null}]}
data: {"id": "chatcmpl-a1B2c3D4e5F6", "object": "chat.completion.chunk", "created": 1759766400, "model": "Qwen3-0.6B-GGUF", "choices": [{"index": 0, "delta": {"content": " capital"}, "finish_reason": null}]}
...
data: {"id": "chatcmpl-a1B2c3D4e5F6", "object": "chat.completion.chunk", "created": 1759766400, "model": "Qwen3-0.6B-GGUF", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
data: [DONE]
```
### Response format
- `x_lemonade_route` - The routing decision, when `model` names a `collection.router` model. See [Routing](./router.md#routing-collectionrouter) for the decision object.
### Errors
| Status | Code | When |
|--------|------|------|
| `400` | `invalid_json` | The body is not a JSON object |
| `400` | `missing_argument` | A required argument is missing |
| `400` | `invalid_argument` | An argument or a value nested in it has the wrong JSON type, or a value its schema does not list |
| `404` | `model_not_supported` | The requested model is filtered out on this system, such as an NPU model without an NPU |
| `404` | `model_not_found` | The requested model is not in the registry |
| `409` | `router_residency_conflict` | Loading the requested model would evict a model that a `collection.router` model needs resident on the same NPU |
| `409` | `slots_pinned_error` | Every loaded model of the requested model's type is pinned, so none can be unloaded to make room |
| `500` | | The backend reports an error; the backend's own status replaces this one when it sends one |
| `500` | `model_load_error` | Loading the requested model fails for another reason, such as a failed download or a backend that does not start |
| `500` | | The request fails unexpectedly |
<!-- END GENERATED: openai.chat_completions -->
Appendix A: PR History
This appendix holds the PR data behind the design: all 225 PRs that edited server.cpp from Oct 30, 2025 to Sep 28, 2026, minus 12 mechanical sweeps such as reformatting, plus the 45 open PRs that edit it as of Oct 5, 2026.
Each kind of PR, with its share of the last 6 months (133 PRs), what it edited in the single-file server, and where it lands under this spec:
| Kind of PR | Share | Edited in the single-file server | Lands in |
|---|---|---|---|
| Change one endpoint | 35% | One handler; 29% also a shared helper | One route file |
| Add an endpoint | 15% | Handler, route table (100%), server.h (97%) |
One new route file, one line in its folder's routes.cpp |
| Config key with a runtime effect | 9% | Constructor (60%), config switch (55%), server.h (50%) |
ConfigEffects |
| Same change across many handlers | 8% | 3 or more handlers | ModelRoute, ModelLoader or one shared helper |
| New subsystem | 8% | Constructor, route table, server.h, includes |
A new route folder or service, plus one line in Server |
| Middleware | 8% | Pre-routing lambda or authenticate_request() |
RequestMiddleware |
| Model JSON field | 6% | model_info_to_json(), often a second copy |
ModelJson |
| Listener and process lifecycle | 5% | run(), stop() |
HttpListener |
| Remove an endpoint | 4% | Handler, route table, server.h |
One route file and its registration line |
| Web UI serving | 2% | setup_static_files() |
WebUi |
Findings that shaped specific rules:
- Across all PRs, 37% edited
server.hand 24% the include block, the two spots unrelated PRs most often both edit. Route classes have no headers. - Replayed against the folder layout, 14 of the 19 PRs that would have touched 3 or more route folders were changing model loading, load errors, router dispatch or telemetry. These have one home in
ModelRouteor a shared helper. - 9 of the open PRs edit collections and routing. Hence the
router/folder.
Appendix B: Error Format Cleanup
This appendix describes a change that unifies error shapes, which vary by route: some return {"error": "message"}, some an OpenAI-style object, and invalid JSON gets a 400 from chat/completions but a 500 from completions.
Each route folder adopts the error format of the API its page documents:
| Route folder | Error format |
|---|---|
openai/ |
{"error": {"message", "type", "code"}} |
anthropic/ |
{"type": "error", "error": {"type", "message"}} |
ollama/ |
{"error": "message"} |
lemonade/, internal/, router/, llamacpp/ |
{"error": {"message", "type", "code"}} |
Each route's error_format follows its folder, each route writes its RouteErrors in that format, and pull's and install's streams send their error events in the same shape. The write_exception() overrides and their error lists are removed. The same PR updates the clients that read error as a string, each a one-line change to accept either shape:
- CLI:
src/cpp/cli/main.cpp:1164reads/internal/set's error, andsrc/cpp/cli/lemonade_client.cpp:1037,1082and1423read theerrorevents of pull's and install's streams. Line 1423 callsget<std::string>()without a type check and would throw. - GUI:
src/app/src/renderer/utils/backendInstaller.ts:14handles only strings, andsrc/app/src/renderer/components/RouterTestPromptPanel.tsx:194rendersdata?.errordirectly.
Older CLIs and GUIs connected to a newer server would still hit those cases, which is why this change ships on its own.