Conflicts: docs.go (manual, OpenAPI and suites all kept), modelserve Job (trace and eval both kept). The eval routes are added to the OpenAPI route table, update and promote documented with their optional body, and the document regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
273 lines
16 KiB
Go
273 lines
16 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.",
|
|
"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: "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)
|
|
}
|