llmgw ctl guard judge status lists each judge model the policies ask: in the catalog, as a judge, replicas ready, its last failure to answer, and what is wrong in words. enable serves the missing one through model serve - Llama Guard 3 1B on CPUs, role guard, under the name the policies give it - with --replace for a model of that name that is not a judge; the console has it under Do. GET /api/v1/guard/judges, and the summary's judges, put the problem on ctl status and the console's Overview. A Llama Guard file served from the mirror page is added as a judge, under the name the policies ask, rather than as a model clients use the policies never find. ctl says plainly when the API answers with a page. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
322 lines
20 KiB
Go
322 lines
20 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
|
|
}
|
|
|
|
// 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.",
|
|
"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.",
|
|
}
|
|
|
|
// 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 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}/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/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{}},
|
|
|
|
// 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{}},
|
|
|
|
// 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)
|
|
}
|