Files
llmgw/internal/control/adminapi/routes.go
T
Ilker ManapandClaude Opus 5.5 d8ce573bdb
CI / test (push) Failing after 7m1s
Slurm's shared files and users: one path everywhere, accounts as Linux users
slurm.shared mounts the site's NFS export, has an llmgw node export the path
over NFS to the Slurm machines alone (2049 in the firewall), or looks for a
file system the site mounts. slurm.users "llmgw" makes each active account a
Linux user on every Slurm machine: login from its e-mail, a UID from 200000
kept in slurm_users and never reused, its home on the shared path, its SSH
keys from the portal's Profile (or ctl slurm users keys) read by sshd from
/etc/llmgw/ssh-keys. server init --slurm chooses /shared from the first
machine and llmgw's users; --slurm-shared and --slurm-users change that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:34:34 +01:00

521 lines
36 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"
"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/control/slurmsvc"
"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.",
"users/{user}": "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. With purge, its " +
"files go too: every version of it the mirror holds (a peer mirror follows), and the nodes' copies once its " +
"replicas have stopped, waiting up to a minute for them; what another model uses stays. The answer is then a PurgeView.",
Query: []Param{{Name: "purge", Type: "boolean", Description: "Delete its files from the mirror and the nodes as well."}},
Response: Removed{}},
{Method: "POST", Path: "/api/v1/models/{name}/stop", Summary: "Take a model out of service and keep it",
Description: "None of its replicas run, and requests are told an administrator stopped it; what it was scaled to is kept for start.",
Response: ScaleResponse{}},
{Method: "POST", Path: "/api/v1/models/{name}/start", Summary: "Run a stopped model again, scaled as it was",
Response: ScaleResponse{}},
{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}/replicas", Summary: "How many replicas run on each profile",
Description: "Seven on cards and four on CPUs, under the model's name. A profile not named keeps its count; " +
"cpu adds a profile on CPUs to a llama.cpp model that has none. Its scaling becomes the sum.",
Request: ProfileReplicasRequest{}, Response: catalog.Model{}},
{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/routing", Summary: "The Ready replicas a standby routes to",
Description: "Read by a standby control plane when router.active_active is on: each replica's address, " +
"model, profile, pool and weight, without its key, and which catalog is in force.",
Response: control.Routing{}},
{Method: "POST", Path: "/api/v1/routing/activity", Summary: "A standby's router reports what it used",
Description: "When it last sent each replica a request, and the on-demand models it was asked for, " +
"so the leader does not evict a replica busy behind a standby. Answered with the routing, " +
"so one request a tick does both.",
Request: control.Activity{}, Response: control.Routing{}},
{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 and longest answer: the catalog, its engines' arguments and memory, then its replicas restarted; max_output alone changes only the catalog and restarts nothing",
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: "GET", Path: "/api/v1/slurm", Summary: "The Slurm cluster beside llmgw, and where each of its nodes is",
Description: "From the cluster's settings (slurm.*) and what each node's agent last said. A node's phase is llmgw " +
"(drained in Slurm, no job left), lending (its replicas moving off), lent, reclaiming (drained in Slurm, jobs " +
"still running) or unknown (its agent has not said).",
Response: slurmsvc.Status{}},
{Method: "GET", Path: "/api/v1/slurm/users", Summary: "The Linux users made from llmgw's accounts for the Slurm cluster",
Description: "Every active account, with its login, UID and SSH keys; an account is given its login and UID the first " +
"time it is listed. The machines have them when slurm.users is \"llmgw\".",
Response: []slurmsvc.User{}},
{Method: "PUT", Path: "/api/v1/slurm/users/{user}/ssh-keys", Summary: "Replace an account's SSH public keys",
Description: "{user} is an ID or e-mail. keys is OpenSSH public keys, one a line, without options; empty takes them " +
"all away. The machines have them within a minute.",
Request: SlurmKeysRequest{}, Response: slurmsvc.User{}},
{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)
}