Files
llmgw/internal/control/adminapi/routes.go
T
Ilker ManapandClaude Opus 5.5 e1cff0d4e3
CI / test (push) Successful in 10m28s
A served model can be moved to more cards, or fewer
llmgw ctl model gpus MODEL N [--profile P] [--in-place],
POST /api/v1/models/{name}/gpus and the console's GPUs…: how many cards of
one node each replica uses. Tensor parallel for vLLM, SGLang and
TensorRT-LLM, held to the model's KV heads (modelplan.SplitsHeads, now one
rule for the planner and this); the layers split for llama.cpp, its old
--tensor-split dropped. Refused for a count no node has, for cards that
would not hold the weights and the context, and for engines on one card.
The replicas start again beside the old ones; with no room beside them the
command says so, and --in-place stops each first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:40:49 +01:00

483 lines
32 KiB
Go

package adminapi
import (
"net/http"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrics"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/federation"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/nvlink"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Route is one endpoint as the OpenAPI document describes it.
//
// A table written by hand beside Register rather than read out of the
// handlers, because what a handler decodes and answers is not visible from
// outside it. The table is checked against Register by a test, so an endpoint
// cannot be added without being described; the bodies are Go values whose
// types the generator reads, so a field added to one is in the document the
// next time it is generated.
type Route struct {
Method string
Path string
// Summary is one line; Description, when there is more to say, the rest.
Summary string
Description string
// Query is the query parameters the handler reads.
Query []Param
// Request is a value of the body's type; nil reads none.
Request any
// OptionalBody is set when the body may be left out.
OptionalBody bool
// Response is a value of the type a success answers with.
Response any
// Status is the success status; zero is 200.
Status int
// CSV is set when the answer is CSV unless JSON is asked for.
CSV bool
// Form is a request sent as multipart/form-data — a file uploaded —
// whose fields are Request's.
Form bool
// Stream is an answer that may come as server-sent events instead.
Stream bool
// Binary is the type of an answer that is a file, not JSON.
Binary string
}
// Param is a query parameter.
type Param struct {
Name string
Description string
// Type is a JSON Schema type: string when empty, or integer, number,
// boolean.
Type string
Required bool
}
// PathParams describes the parameters in Routes' paths, by the segment
// before them and their name.
var PathParams = map[string]string{
"models/{name}": "The model's name in the catalog.",
"presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.",
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"secrets/{name}": "ldap-bind, oidc-client-secret or scim-token.",
"servers/{name}": "The MCP server's name: its tools are NAME.tool in /mcp.",
"jobs/{id}": "The job's ID, as starting it answered.",
"runs/{id}": "The quality run's ID, as starting it answered.",
"requests/{id}": "The request's ID: the x-request-id a client was answered with.",
"client-jobs/{id}": "The job's ID, job_….",
"fabrics/{id}": "The physical fabric's ID, as GET /api/v1/fabrics lists it.",
"quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.",
"federation/{peer}": "The federation peer's name, as `llmgw ctl federation list` lists it.",
"holds/{domain}": "The domain as level:name, rack:r12-07, as `llmgw ctl facility` lists it.",
"collections/{id}": "The collection's ID, its name when that is unique, or ORG/NAME.",
"orgs/{id}": "The organization's ID or name.",
"teams/{id}": "The team's ID.",
"projects/{id}": "The project's ID.",
"keys/{id}": "The key's ID, as listed (not the key itself).",
"users/{id}": "The person's ID or email.",
"artifacts/{kind}": "What the job made: peft, a gzipped tar of the PEFT adapter, or gguf, the adapter for llama.cpp.",
}
// Routes is every endpoint Register mounts.
var Routes = []Route{
// The document itself.
{Method: "GET", Path: "/api/v1/openapi.json", Summary: "This document",
Description: "The admin API in OpenAPI 3.1, generated from the route table and built into the binary.",
Response: map[string]any{}},
// The fleet.
{Method: "GET", Path: "/api/v1/summary", Summary: "The whole fleet in one answer",
Response: Summary{}},
{Method: "GET", Path: "/api/v1/nodes", Summary: "Every node, with its cards, load and fabrics",
Response: []NodeView{}},
{Method: "POST", Path: "/api/v1/nodes/{id}/state", Summary: "Cordon, drain or ready a node",
Request: NodeStateRequest{}, Response: NodeStateView{}},
{Method: "POST", Path: "/api/v1/nodes/{id}/upgrade", Summary: "Have a node run a release",
Request: UpgradeRequest{}, Response: UpgradeView{}},
{Method: "POST", Path: "/api/v1/nodes/{id}/engines", Summary: "Choose an engine's build for a node",
Description: "The node installs the build chosen. Build is auto, a build such as cu129, or none.",
Request: NodeEngineRequest{}, Response: NodeEngineRequest{}},
{Method: "POST", Path: "/api/v1/nodes/{id}/labels", Summary: "Set or remove labels on a node, over its agent's own",
Description: "Kept in the store. llmgw.io/site, room, row, rack and pdu say where the node is, for facility-aware placement.",
Request: NodeLabelsRequest{}, Response: NodeLabelsView{}},
{Method: "GET", Path: "/api/v1/facility", Summary: "The failure domains, their power against their caps, and what is held",
Response: FacilityView{}},
{Method: "POST", Path: "/api/v1/facility/holds", Summary: "Hold a domain for maintenance: nothing new is placed in it",
Description: "With drain, each of its nodes is drained too, one replica at a time with the replacement up first.",
Request: HoldRequest{}, Response: HoldView{}},
{Method: "DELETE", Path: "/api/v1/facility/holds/{domain}", Summary: "Release a held domain",
Description: "Nodes a draining hold drained are made ready again.",
Response: HoldView{}},
{Method: "DELETE", Path: "/api/v1/nodes/{id}", Summary: "Forget a node that has gone",
Description: "Only a node that is lost, or unreachable with nothing on it; its certificate is revoked.",
Response: NodeRemoved{}},
{Method: "GET", Path: "/api/v1/nodes/{id}/logs", Summary: "The last lines of a node's logs",
Query: []Param{
{Name: "lines", Type: "integer", Description: "How many lines; 300 when absent, at most 2000."},
{Name: "source", Description: "Which log: agent when absent, or an engine the node lists in sources."},
},
Response: LogsView{}},
{Method: "GET", Path: "/api/v1/fabrics", Summary: "The physical fabrics and the names nodes give them",
Response: fabrics.Report{}},
{Method: "GET", Path: "/api/v1/fabrics/{id}/topology", Summary: "One fabric's map, with the cluster's adapters live on it",
Response: TopologyView{}},
{Method: "GET", Path: "/api/v1/nvlink", Summary: "The multi-node NVLink domains and what keeps nodes out of them",
Response: nvlink.Report{}},
{Method: "POST", Path: "/api/v1/fabric-test", Summary: "Measure bandwidth between two nodes, rail by rail",
Request: FabricTestRequest{}, Response: FabricTestView{}},
{Method: "GET", Path: "/api/v1/weights/orphans", Summary: "Weights on nodes that no model uses",
Response: []OrphanView{}},
{Method: "POST", Path: "/api/v1/weights/collect", Summary: "Delete weights that no model uses",
Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}},
// The catalog.
{Method: "GET", Path: "/api/v1/presets", Summary: "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
Response: PresetsView{}},
{Method: "POST", Path: "/api/v1/presets/{name}", Summary: "Add a preset to the catalog, and clear its decline",
Response: ModelAdded{}},
{Method: "PUT", Path: "/api/v1/presets/auto", Summary: "Turn on or off the adding of presets when a node fits them",
Request: PresetsAutoRequest{}, Response: PresetsView{}},
{Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state",
Response: []ModelView{}},
{Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog",
Description: "Kept in the store across restarts. Refused when the name is taken, a model in " +
"configuration has it, or the definition would make the catalog invalid.",
Request: catalog.Model{}, Response: ModelAdded{}},
{Method: "GET", Path: "/api/v1/models/{name}", Summary: "One model's definition, as it is in force",
Description: "With the defaults the catalog filled in. By its name only, not an alias.",
Response: catalog.Model{}},
{Method: "DELETE", Path: "/api/v1/models/{name}", Summary: "Remove a model added at runtime",
Description: "A model declared in the cluster's settings is refused: it is changed there.",
Response: Removed{}},
{Method: "POST", Path: "/api/v1/models/{name}/scale", Summary: "Set how many replicas a model runs",
Request: ScaleRequest{}, Response: ScaleResponse{}},
{Method: "POST", Path: "/api/v1/models/{name}/resources", Summary: "Set what a model's profiles reserve of a node",
Request: ResourcesRequest{}, Response: catalog.Model{}},
{Method: "POST", Path: "/api/v1/models/{name}/class", Summary: "Set a model's capacity class",
Request: ClassRequest{}, Response: ClassView{}},
{Method: "POST", Path: "/api/v1/models/{name}/owner", Summary: "Set the part of the tenancy tree a model's capacity counts against",
Request: OwnerRequest{}, Response: OwnerView{}},
{Method: "POST", Path: "/api/v1/models/{name}/adapters", Summary: "Replace a model's LoRA adapters",
Description: "Each new file is read first, to check it is an adapter for the model's architecture.",
Request: AdaptersRequest{}, Response: AdaptersRequest{}},
{Method: "POST", Path: "/api/v1/models/{name}/retry", Summary: "Try a parked model again",
Response: RetryView{}},
{Method: "GET", Path: "/api/v1/models/{name}/target", Summary: "How many replicas a throughput needs, and whether they fit",
Query: []Param{{Name: "tokens_per_sec", Type: "number", Required: true,
Description: "The output tokens a second wanted of the model."}},
Response: TargetView{}},
{Method: "GET", Path: "/api/v1/models/{name}/headroom", Summary: "How many more replicas of a model fit now, and where",
Query: []Param{{Name: "limit", Type: "integer",
Description: "The most to try placing; 16 when absent or past 64."}},
Response: HeadroomView{}},
{Method: "PUT", Path: "/api/v1/models/{name}/capacity", Summary: "Record what a replica was measured to serve",
Description: "Replaces an earlier measurement of the same model on the same profile.",
Request: bench.Capacity{}, Response: bench.Capacity{}},
{Method: "GET", Path: "/api/v1/capacity", Summary: "Every recorded measurement",
Response: []CapacityView{}},
{Method: "GET", Path: "/api/v1/replicas", Summary: "Every replica, with its shards",
Query: []Param{{Name: "model", Description: "Only this model's replicas."}},
Response: []ReplicaView{}},
{Method: "GET", Path: "/api/v1/explain-placement", Summary: "Why a model could not be placed",
Query: []Param{{Name: "model", Required: true, Description: "The model's name or an alias."}},
Response: ExplainView{}},
{Method: "POST", Path: "/api/v1/restart", Summary: "Restart replicas, every replica of some models, or all",
Request: RestartRequest{}, Response: RestartResult{}, Status: http.StatusAccepted},
// Serving from the registry, and newer weights.
{Method: "GET", Path: "/api/v1/plan", Summary: "How a registry repository would be served here",
Query: []Param{
{Name: "repo", Required: true, Description: "The repository, owner/name."},
{Name: "format", Description: "gguf or safetensors; gguf when absent."},
{Name: "context", Type: "integer", Description: "The context length to plan for, in tokens."},
},
Response: modelserve.Plan{}},
{Method: "POST", Path: "/api/v1/models/serve", Summary: "Fetch a registry repository and serve it",
Request: modelserve.Request{}, Response: modelserve.Job{}, Status: http.StatusAccepted},
{Method: "GET", Path: "/api/v1/jobs", Summary: "Every job since the control plane started",
Response: []modelserve.Job{}},
{Method: "GET", Path: "/api/v1/jobs/{id}", Summary: "One job",
Response: modelserve.Job{}},
{Method: "GET", Path: "/api/v1/updates", Summary: "Newer weights the registry has for models",
Response: []modelserve.UpdateInfo{}},
{Method: "POST", Path: "/api/v1/updates/check", Summary: "Ask the registry now, about one model or every one",
Request: CheckRequest{}, OptionalBody: true, Response: []modelserve.UpdateInfo{}},
{Method: "POST", Path: "/api/v1/models/{name}/update", Summary: "Move a model to its repository's newer weights",
Description: "With a quality suite, or the model's standing gate, the new weights are asked it first as a hidden canary.",
Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted},
{Method: "POST", Path: "/api/v1/models/{name}/context", Summary: "Change a served model's context: the catalog, its engines' arguments and memory, then its replicas restarted",
Request: modelserve.ContextRequest{}, Response: modelserve.ContextChange{}},
{Method: "POST", Path: "/api/v1/models/{name}/gpus", Summary: "Spread a served model over more cards or fewer: its profile, checked against its KV heads, its cards' memory and the nodes, then its replicas restarted",
Description: "Tensor parallel for vLLM, SGLang and TensorRT-LLM, a layer split for llama.cpp, on one node. With no room beside the running replicas, in_place stops each first.",
Request: modelserve.GPUsRequest{}, Response: modelserve.GPUsChange{}},
{Method: "POST", Path: "/api/v1/models/{name}/rollback", Summary: "Go back to the weights the last update replaced",
Response: Result{}},
{Method: "POST", Path: "/api/v1/models/{name}/origin", Summary: "Record the repository a model's weights came from",
Request: OriginRequest{}, Response: OriginView{}},
{Method: "GET", Path: "/api/v1/canaries", Summary: "Every canary, and how its two sides serve",
Response: []modelserve.CanaryView{}},
{Method: "POST", Path: "/api/v1/models/{name}/canary", Summary: "Start a canary of a model's newer weights",
Request: catalog.Canary{}, Response: modelserve.Job{}, Status: http.StatusAccepted},
{Method: "PUT", Path: "/api/v1/models/{name}/canary", Summary: "Change the share of requests a canary takes",
Request: CanaryPercentRequest{}, Response: Result{}},
{Method: "DELETE", Path: "/api/v1/models/{name}/canary", Summary: "Take a canary out: its share first, then the candidate",
Response: Result{}},
{Method: "POST", Path: "/api/v1/models/{name}/promote", Summary: "Make a canary's weights the model's",
Description: "With a quality suite, or the model's standing gate, the canary is asked it first and kept below the score.",
Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted},
// Quality suites.
{Method: "GET", Path: "/api/v1/eval/suites", Summary: "The quality suites the control plane can run by name",
Response: []eval.SuiteInfo{}},
{Method: "POST", Path: "/api/v1/models/{name}/eval", Summary: "Ask a model a quality suite",
Description: "Started at once and answered with the run, to be followed at /api/v1/eval/runs/{id}.",
Request: eval.Request{}, Response: eval.Live{}, Status: http.StatusAccepted},
{Method: "GET", Path: "/api/v1/eval/runs", Summary: "Quality runs, the newest first",
Query: []Param{
{Name: "model", Description: "Only this model's runs."},
{Name: "limit", Type: "integer", Description: "How many; a sensible number when absent."},
},
Response: []eval.Live{}},
{Method: "GET", Path: "/api/v1/eval/runs/{id}", Summary: "One quality run, with each case",
Response: eval.Live{}},
{Method: "PUT", Path: "/api/v1/models/{name}/eval-gate", Summary: "Hold a model's updates and promotions to a quality suite",
Request: EvalGateRequest{}, Response: EvalGateView{}},
{Method: "DELETE", Path: "/api/v1/models/{name}/eval-gate", Summary: "Stop holding a model's updates to a suite",
Response: EvalGateView{}},
// Federation: other llmgw clusters as providers.
{Method: "GET", Path: "/api/v1/federation", Summary: "This cluster's id, and its federation peers with their health and models",
Response: FederationView{}},
{Method: "POST", Path: "/api/v1/federation", Summary: "Add another llmgw cluster as a peer, or change one",
Description: "The peer is checked first: its /ready, timed, and its /v1/models with the key. Its models join the catalog as PEER/MODEL.",
Request: federation.AddRequest{}, Response: federation.Status{}, Status: http.StatusCreated},
{Method: "DELETE", Path: "/api/v1/federation/{peer}", Summary: "Remove a federation peer and its models",
Description: "Refused while a model here falls back to one of the peer's.",
Response: Removed{}},
{Method: "POST", Path: "/api/v1/federation/{peer}/sync", Summary: "Check a peer now, rather than at the next interval",
Response: federation.Status{}},
{Method: "PUT", Path: "/api/v1/models/{name}/fallbacks", Summary: "Replace the models a model falls back to when it has no capacity",
Description: "A model added at runtime only. A federation peer's model, PEER/MODEL, sends the overflow to the other cluster.",
Request: FallbacksRequest{}, Response: FallbacksView{}},
// Tenancy: quotas and guardrails.
{Method: "GET", Path: "/api/v1/quotas", Summary: "Every capacity quota, with what its scope holds now",
Response: []QuotaView{}},
{Method: "PUT", Path: "/api/v1/quotas", Summary: "Set a scope's capacity quota",
Description: "Replaces the scope's quota whole. The scope may be named by path or by ID; the answer names it by ID.",
Request: QuotaRequest{}, Response: QuotaSet{}},
{Method: "DELETE", Path: "/api/v1/quotas/{scope}", Summary: "Remove a scope's capacity quota",
Response: Removed{}},
{Method: "GET", Path: "/api/v1/guard", Summary: "The guardrail policies and where they are attached",
Response: GuardView{}},
{Method: "GET", Path: "/api/v1/guard/events", Summary: "What the guardrails did: totals and the newest requests",
Query: []Param{
{Name: "since", Description: "How far back: 90m, 24h, 7d; 24h when absent."},
},
Response: GuardEventsView{}},
{Method: "GET", Path: "/api/v1/guard/judges", Summary: "The judge models the policies ask, and what is wrong with each",
Response: []JudgeView{}},
{Method: "POST", Path: "/api/v1/guard/judges/ask", Summary: "Have a judge read a text and say what it answered; nothing is kept",
Request: JudgeAskRequest{}, Response: JudgeAskView{}},
{Method: "POST", Path: "/api/v1/guard/judges/check", Summary: "How a judge keeps up: three judgments in turn, then a burst at once, against the budget",
Request: JudgeCheckRequest{}, Response: JudgeCheckView{}},
{Method: "POST", Path: "/api/v1/guard/attachments", Summary: "Attach a guardrail policy",
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
{Method: "DELETE", Path: "/api/v1/guard/attachments", Summary: "Detach a guardrail policy",
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
// Knowledge bases: collections of documents, which people fill with
// llmgw-kb from their own machines through the gateway.
{Method: "GET", Path: "/api/v1/kb", Summary: "Every knowledge-base collection, with its documents' count and grants",
Description: "Says why there are none when the store cannot hold them (SQLite, or PostgreSQL without pgvector).",
Response: KBView{}},
{Method: "POST", Path: "/api/v1/kb/collections", Summary: "Make a collection, owned by an organization, team, project or person",
Description: "The embedding model's dimension is asked of the model when dims is not given, so the model must be able to serve.",
Request: KBCreateRequest{}, Response: KBCollectionView{}, Status: http.StatusCreated},
{Method: "DELETE", Path: "/api/v1/kb/collections/{id}", Summary: "Remove a collection with its documents and grants",
Response: map[string]any{}},
{Method: "GET", Path: "/api/v1/kb/collections/{id}/documents", Summary: "A collection's documents",
Response: []KBDocumentView{}},
{Method: "DELETE", Path: "/api/v1/kb/collections/{id}/documents", Summary: "Remove one document from a collection",
Query: []Param{{Name: "name", Description: "The document's name, as listed.", Required: true}},
Response: map[string]any{}},
{Method: "POST", Path: "/api/v1/kb/grants", Summary: "Let a scope, and everything below it, read or write a collection",
Description: "Never outside the collection's organization.",
Request: KBGrantRequest{}, Response: KBGrantView{}},
{Method: "DELETE", Path: "/api/v1/kb/grants", Summary: "Take a grant away",
Request: KBGrantRequest{}, Response: KBGrantView{}},
{Method: "POST", Path: "/api/v1/kb/defaults", Summary: "Set the collections a part of the tree answers from when a request names none",
Description: "On an organization, team, project or key; the narrowest one with a default wins. No collections clears it. " +
"A request's own X-Llmgw-Knowledge overrides it, and X-Llmgw-Knowledge: none turns it off.",
Request: KBDefaultRequest{}, Response: KBDefaultView{}},
{Method: "POST", Path: "/api/v1/kb/collections/{id}/dataset", Summary: "Training data from a collection: questions and answers a chat model writes from each chunk",
Description: "NDJSON, a line per pair as each chunk is read, or a line with error for a chunk that gave none. For people to correct before an adapter is trained on it.",
Request: KBDatasetRequest{}, Response: KBDatasetLine{}},
{Method: "POST", Path: "/api/v1/kb/search", Summary: "Try a search of a collection, as an administrator",
Description: "No tenancy applies: this is for checking what a collection holds and finds.",
Request: KBSearchRequest{}, Response: []KBHitView{}},
// LoRA adapters trained on the nodes.
// Organizations, teams, projects, keys and people.
{Method: "GET", Path: "/api/v1/orgs", Summary: "The tenancy tree: every organization, its teams and their projects",
Response: []OrgView{}},
{Method: "POST", Path: "/api/v1/orgs", Summary: "Add an organization",
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
{Method: "PATCH", Path: "/api/v1/orgs/{id}", Summary: "Rename an organization",
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
{Method: "DELETE", Path: "/api/v1/orgs/{id}", Summary: "Remove an organization that has no teams",
Response: map[string]any{}},
{Method: "POST", Path: "/api/v1/teams", Summary: "Add a team to an organization",
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
{Method: "PATCH", Path: "/api/v1/teams/{id}", Summary: "Rename a team, or move it to another organization",
Description: "Everything under it moves too, and from then on inherits the new organization's limits.",
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
{Method: "DELETE", Path: "/api/v1/teams/{id}", Summary: "Remove a team that has no projects",
Response: map[string]any{}},
{Method: "POST", Path: "/api/v1/projects", Summary: "Add a project to a team",
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
{Method: "PATCH", Path: "/api/v1/projects/{id}", Summary: "Rename a project, or move it to another team",
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
{Method: "DELETE", Path: "/api/v1/projects/{id}", Summary: "Remove a project with no people, keys or collections",
Description: "A project that ever had a key keeps it, revoked, because usage refers to it; rename such a project instead.",
Response: map[string]any{}},
{Method: "GET", Path: "/api/v1/keys", Summary: "API keys, with their project and owner; never the keys themselves",
Query: []Param{{Name: "project", Description: "Only this project's keys: ORG/TEAM/PROJECT or its ID."}},
Response: []KeyView{}},
{Method: "POST", Path: "/api/v1/keys", Summary: "Issue an API key",
Description: "The answer holds the key, once: only its hash is kept. A lifetime longer than a scope above it allows is refused.",
Request: KeyCreateRequest{}, Response: KeyCreated{}, Status: http.StatusCreated},
{Method: "GET", Path: "/api/v1/keys/{id}", Summary: "One API key", Response: KeyView{}},
{Method: "DELETE", Path: "/api/v1/keys/{id}", Summary: "Revoke an API key",
Description: "A soft delete: usage refers to it. A gateway that has it cached accepts it until auth.cache_ttl.",
Response: map[string]any{}},
{Method: "GET", Path: "/api/v1/users", Summary: "People, with where each is a member",
Query: []Param{{Name: "state", Description: "Only people in this state: pending, active or disabled."}},
Response: []UserView{}},
{Method: "GET", Path: "/api/v1/users/{id}", Summary: "One person, with where they are a member", Response: UserView{}},
{Method: "POST", Path: "/api/v1/users/{id}/memberships", Summary: "Put a person in an organization, team or project",
Description: "Already there, only the role changes.",
Request: MembershipRequest{}, Response: UserView{}},
{Method: "DELETE", Path: "/api/v1/users/{id}/memberships", Summary: "Take a person out of an organization, team or project",
Description: "Their keys in projects they no longer reach are revoked.",
Request: MembershipRequest{}, Response: UserView{}},
{Method: "POST", Path: "/api/v1/train/jobs", Summary: "Queue a LoRA adapter's training on a node",
Description: "The node trains when its agent is there, nothing else trains on it, and a card has gpu_memory_mib free; " +
"the pairs are NDJSON of {\"q\", \"a\"}. An adapter learns how answers sound far better than what they say.",
Request: TrainRequest{}, Response: TrainJobView{}, Status: http.StatusCreated},
{Method: "GET", Path: "/api/v1/train/jobs", Summary: "Training jobs, newest first",
Query: []Param{{Name: "limit", Description: "How many; 50 when absent.", Type: "integer"}},
Response: []TrainJobView{}},
{Method: "GET", Path: "/api/v1/train/jobs/{id}", Summary: "One training job: how it goes, and what it made",
Response: TrainJobView{}},
{Method: "POST", Path: "/api/v1/train/jobs/{id}/cancel", Summary: "Stop a training job",
Response: TrainJobView{}},
{Method: "DELETE", Path: "/api/v1/train/jobs/{id}", Summary: "Remove an ended training job and what it made",
Response: map[string]any{}},
{Method: "POST", Path: "/api/v1/train/jobs/{id}/publish", Summary: "Make what a finished job made the model's adapter",
Description: "Kept on the mirror — the GGUF for a model llama.cpp serves, the PEFT files for one vLLM serves — and added to the model's adapters after the same check as POST /api/v1/models/{name}/adapters.",
Response: TrainPublishView{}},
{Method: "GET", Path: "/api/v1/train/jobs/{id}/artifacts/{kind}", Summary: "What a training job made: peft (a gzipped tar) or gguf",
Response: map[string]any{}},
{Method: "POST", Path: "/api/v1/agents/restart", Summary: "Have every node's agent start again; their engines keep serving",
Response: AgentRestartView{}},
// The cluster's settings: everything in server.yaml that is not one
// machine's own, the same on every control plane.
{Method: "GET", Path: "/api/v1/settings", Summary: "The cluster's settings in force, or one earlier version",
Query: []Param{{Name: "version", Description: "An earlier version; the newest when absent."}},
Response: SettingsView{}},
{Method: "GET", Path: "/api/v1/settings/history", Summary: "Versions of the cluster's settings, newest first",
Query: []Param{{Name: "limit", Description: "How many; 20 when absent."}},
Response: []SettingsView{}},
{Method: "PUT", Path: "/api/v1/settings", Summary: "Change the cluster's settings: in force on every control plane within seconds",
Request: SettingsRequest{}, Response: SettingsView{}},
{Method: "POST", Path: "/api/v1/settings/rollback", Summary: "Put an earlier version of the cluster's settings back in force",
Request: SettingsRollbackRequest{}, Response: SettingsView{}},
// How people sign in: single sign-on, a directory, and the secrets
// they need kept for every control plane.
{Method: "GET", Path: "/api/v1/auth", Summary: "How people sign in: OIDC, a directory, SCIM, and the directory's last sync",
Response: AuthView{}},
{Method: "POST", Path: "/api/v1/auth/ldap/test", Summary: "Try an auth.ldap block before it is kept: servers, the service account, people and groups",
Request: LDAPTestRequest{}, Response: LDAPTestView{}},
{Method: "PUT", Path: "/api/v1/auth/secrets/{name}", Summary: "Keep ldap-bind, oidc-client-secret or scim-token in the store, for settings to name as store:auth/NAME",
Request: SecretRequest{}, Response: map[string]string{}},
// The company's own MCP servers, served through /mcp.
{Method: "POST", Path: "/api/v1/mcp", Summary: "This cluster as a read-only MCP server: status, nodes, models, replicas, placement, logs",
Description: "JSON-RPC: initialize, tools/list, tools/call. Registered with the gateway as llmgw:admin.",
Request: map[string]any{}, Response: map[string]any{}},
{Method: "GET", Path: "/api/v1/mcp/servers", Summary: "The company's MCP servers /mcp serves, and how each is reached",
Response: []MCPServerView{}},
{Method: "PUT", Path: "/api/v1/mcp/servers/{name}", Summary: "Add an MCP server, or change one: its address, headers and the secrets they carry",
Request: MCPServerRequest{}, Response: MCPServerView{}},
{Method: "DELETE", Path: "/api/v1/mcp/servers/{name}", Summary: "Remove an MCP server: its tools leave /mcp",
Response: map[string]string{}},
{Method: "POST", Path: "/api/v1/mcp/servers/{name}/test", Summary: "Ask an MCP server for its tools, with its credentials, as /mcp would",
Response: MCPTestView{}},
// Usage, requests and the record of changes.
{Method: "GET", Path: "/api/v1/usage", Summary: "What each group of the tenancy tree spent",
Query: []Param{
{Name: "by", Description: "org, team or project; team when absent."},
{Name: "since", Description: "A duration back from now, like 24h; 24h when absent."},
},
Response: []ScopeUsageView{}},
{Method: "GET", Path: "/api/v1/usage/export", Summary: "Usage totals by period and dimension, as CSV or JSON",
Query: []Param{
{Name: "from", Description: "A date (2026-09-01) or an RFC 3339 time; the first of this month when absent."},
{Name: "to", Description: "A date or an RFC 3339 time; now when absent."},
{Name: "period", Description: "hour, day or month; day when absent."},
{Name: "by", Description: "Dimensions, comma-separated: org, team, project, user, key, model."},
{Name: "format", Description: "json for JSON; CSV otherwise."},
},
Response: []store.ExportRow{}, CSV: true},
{Method: "GET", Path: "/api/v1/client-jobs", Summary: "Jobs clients asked for — 3D models — of every owner, newest first",
Query: []Param{
{Name: "status", Description: "Only jobs in this status: queued, running, cancelling, completed, failed, cancelled, expired."},
{Name: "limit", Type: "integer", Description: "The most jobs to answer with; 50 when absent."},
},
Response: []JobView{}},
{Method: "GET", Path: "/api/v1/client-jobs/{id}", Summary: "One job a client asked for", Response: JobView{}},
{Method: "POST", Path: "/api/v1/client-jobs/{id}/cancel", Summary: "Stop a job, in the engine's queue too", Response: JobView{}},
{Method: "GET", Path: "/api/v1/requests/{id}", Summary: "What one request went through",
Response: RequestView{}},
{Method: "GET", Path: "/api/v1/requests/{id}/body", Summary: "A kept request and its answer",
Response: store.RequestBody{}},
{Method: "POST", Path: "/api/v1/requests/{id}/replay", Summary: "Send a kept request again, to any model",
Request: ReplayRequest{}, Response: ReplayView{}},
{Method: "GET", Path: "/api/v1/audit", Summary: "Changes made, newest first",
Query: []Param{
{Name: "since", Description: "How far back: 90m, 24h, 7d; 7d when absent."},
{Name: "limit", Type: "integer", Description: "The most events to answer with."},
{Name: "actor", Description: "Only changes by this actor."},
{Name: "contains", Description: "Only events whose action or target contains this."},
},
Response: []store.AuditEvent{}},
// Operations.
{Method: "POST", Path: "/api/v1/notify/test", Summary: "Send a test notification to every webhook",
Response: []notify.Result{}},
{Method: "POST", Path: "/api/v1/metrics/range", Summary: "Several Prometheus range queries at once",
Description: "Read from the Prometheus monitoring.prometheus names; configured is false without one.",
Request: MetricsRequest{}, Response: MetricsResult{}},
}
// serveOpenAPI answers with the document generated from Routes, as it was
// when this binary was built.
func serveOpenAPI(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(llmgw.AdminOpenAPI)
}