Files
llmgw/internal/control/adminapi/routes.go
T
Ilker ManapandClaude Opus 5.5 40e9fd2ab1 Federation: another llmgw cluster as a provider, its models as PEER/MODEL
ctl federation add/list/remove/sync keeps peers in the store (migration
0029); their /v1/models are offered here as external models named
PEER/MODEL and kept in step, a model still named by a fallback kept
withdrawn. Peers are checked at /ready, timed, and taken out of routing
while down. Requests to a peer carry x-llmgw-hop and are refused (508) when
they come back to a cluster they passed through; residency and no_store
keep requests off peers; X-Request-Id and, with --send-tags, attribution
tags go along. ctl model fallbacks sets overflow to a peer. Console view,
metrics, admin API routes and docs.

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

290 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/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.",
}
// 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{}},
// 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: "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)
}