Files
llmgw/internal/control/adminapi/routes.go
T
Ilker ManapandClaude Opus 5.5 0d3eb82b9b Facility-aware placement: racks, PDUs, holds and power caps
Nodes say where they are with llmgw.io/site, room, row, rack and pdu labels,
from agent.yaml or `llmgw ctl label` (kept in the store, over the agent's).
Placement spreads a model's replicas across racks and PDUs, keeps a
multi-node replica within one domain when its profile says keep_within,
places nothing in a domain held with `llmgw ctl facility hold` (optionally
draining it), and refuses a shard that would take a domain past its power
cap from facility.budgets, counting declared, derived and measured draw.
Explain-placement names the domain, and keeps the rejections of a placed
replica; `ctl nodes` gains LOCATION and --domain; `ctl facility` lists the
domains; the console's Nodes view shows and sets location and holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:17:49 +02:00

285 lines
17 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/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
}
// 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.",
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"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.",
"fabrics/{id}": "The physical fabric's ID, as GET /api/v1/fabrics lists it.",
"holds/{domain}": "The domain as level:name, rack:r12-07, as `llmgw ctl facility` lists it.",
"quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.",
}
// 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/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 configuration is refused: it would come back on the next restart.",
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}/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{}},
// 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: "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{}},
// 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/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)
}