From a95c519bc51bf83f3809f60839dafa2bb143754d Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 4 Oct 2026 23:47:59 +0200 Subject: [PATCH] The admin API described in OpenAPI 3.1: generated from a route table by docgen, served at /api/v1/openapi.json adminapi.Routes lists every endpoint with its query parameters and Go values of the bodies it reads and answers with; internal/docgen/openapi turns that into docs/admin-api.openapi.json by reflection, the way encoding/json encodes the types, with the fields' comments as descriptions. A test fails when an endpoint is registered and not in the table, another when the committed document is stale. The binary embeds it and serves it with the token. Handlers that wrote map literals and decoded anonymous structs now use named types, so the document can see them. GET /api/v1/models/{name} answers with a model's whole definition as it is in force, which a tool managing it needs. docs/admin-api.md: reaching the API, the token, what a request must carry. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs.go | 10 +- docs/admin-api.md | 160 + docs/admin-api.openapi.json | 6059 ++++++++++++++++++++++ docs/install.md | 3 +- internal/control/adminapi/api.go | 65 +- internal/control/adminapi/canary.go | 16 +- internal/control/adminapi/fabrictest.go | 5 +- internal/control/adminapi/guard.go | 19 +- internal/control/adminapi/quotas.go | 31 +- internal/control/adminapi/replies.go | 84 + internal/control/adminapi/requestbody.go | 4 +- internal/control/adminapi/routes.go | 249 + internal/control/adminapi/routes_test.go | 119 + internal/control/adminapi/weights.go | 30 +- internal/docgen/gen/main.go | 16 +- internal/docgen/openapi/openapi.go | 672 +++ internal/docgen/openapi/openapi_test.go | 163 + 17 files changed, 7643 insertions(+), 62 deletions(-) create mode 100644 docs/admin-api.md create mode 100644 docs/admin-api.openapi.json create mode 100644 internal/control/adminapi/replies.go create mode 100644 internal/control/adminapi/routes.go create mode 100644 internal/control/adminapi/routes_test.go create mode 100644 internal/docgen/openapi/openapi.go create mode 100644 internal/docgen/openapi/openapi_test.go diff --git a/docs.go b/docs.go index 11e35bc..582bed7 100644 --- a/docs.go +++ b/docs.go @@ -1,4 +1,5 @@ -// Package llmgw holds what the binary carries besides code: its manual. +// Package llmgw holds what the binary carries besides code: its manual, and +// the description of its admin API. package llmgw import "embed" @@ -8,3 +9,10 @@ import "embed" // //go:embed README.md CHANGELOG.md docs/*.md var Docs embed.FS + +// AdminOpenAPI is the admin API described in OpenAPI 3.1, generated from its +// route table by `go generate` in internal/docgen and served at +// /api/v1/openapi.json, so a client can be pointed at the server it talks to. +// +//go:embed docs/admin-api.openapi.json +var AdminOpenAPI []byte diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..964969e --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,160 @@ +# The admin API + +Written for whoever automates a cluster rather than typing at it: how to reach +the control plane's management API, what it asks of a request, and where its +description is. + +`llmgw ctl` and the console are clients of this API and nothing more, so +anything they do can be done with `curl`, a generated client or the +[Terraform provider](../terraform-provider-llmgw/README.md). It adds and +removes models, scales them, cordons and upgrades nodes, sets capacity +quotas, attaches guardrail policies, and reads the fleet, usage and the audit +log. + +## Reaching it + +The API is under `/api/v1` on the admin listener, `admin_listen` in +`/etc/llmgw/server.yaml`: `127.0.0.1:9090` unless changed. It is on loopback +on purpose. The same listener serves pprof when it is turned on, and the +server refuses to start with it on any other address unless +`admin_remote: true` — the token would cross the network in clear. + +From another machine, tunnel to it rather than moving it: + +```sh +ssh -N -L 9090:127.0.0.1:9090 admin@control-plane & +curl -s -H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" http://127.0.0.1:9090/api/v1/summary +``` + +With `admin_remote: true` and `admin_listen: 10.0.0.5:9090`, call that address +directly — on a network you trust, or behind a TLS proxy that keeps the +`Host` header an address. + +## The token + +Every path under `/api` needs the admin token as a bearer token: + +``` +Authorization: Bearer 6f1c… +``` + +The server writes it the first time it starts, 0600, to +`/var/lib/llmgw/admin-token` under its systemd unit (`admin_token_file` +moves it; a server run by hand keeps it in `secrets/admin-token` beside its +configuration). `llmgw ctl` reads it from there, which is why it runs with +sudo, or from `LLMGW_ADMIN_TOKEN`. To give it to automation: + +```sh +sudo cat /var/lib/llmgw/admin-token +``` + +The token is the whole of the API's authorisation: whoever holds it can do +everything the API does. Keep it where you keep other root credentials. + +## What a request must look like + +Besides the token, the listener refuses three things before a handler sees +them, as defences against a browser being turned against it: + +- **A name in `Host`.** The listener answers to an address or `localhost`, + not a DNS name, so a page on some other site cannot reach it by rebinding + a name to 127.0.0.1. Call it as `http://127.0.0.1:9090`, not + `http://cp.example.com:9090`; behind a proxy, have the proxy send an + address. +- **A cross-site request.** One whose `Origin` is another host, or whose + `Sec-Fetch-Site` says another site sent it. +- **A change that is not JSON.** Every method but `GET` and `HEAD` must carry + `Content-Type: application/json`, even a `DELETE` or a `POST` with no body. + A form post from a page cannot set that header. + +A change is recorded in the audit log with who made it, as the request says +in `X-Llmgw-Actor` (letters, digits and `._-@`, at most 64; `unknown` when +absent). `llmgw ctl` sends the name of whoever ran it; automation should send +its own, `terraform` or `ci@deploy`, so `llmgw ctl audit` can tell them apart. + +A refusal is a 4xx or 5xx with `{"error": "…"}` saying why. 501 means this +control plane lacks what the endpoint needs — a store, a mirror, guard +policies — and says which. + +## Some calls + +```sh +H=(-H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" -H "Content-Type: application/json" -H "X-Llmgw-Actor: $USER") +API=http://127.0.0.1:9090/api/v1 + +# The fleet in one answer, and every model. +curl -s "${H[@]}" $API/summary +curl -s "${H[@]}" $API/models + +# Add a model, read back its definition with the defaults filled in, scale it. +curl -s "${H[@]}" -X POST $API/models -d '{ + "name": "qwen-small", + "source": "/srv/models/qwen2.5-1.5b-instruct-q4_k_m.gguf", + "profiles": [{"name": "cpu", "engine": "llamacpp", "min_memory_gb": 4, "cpu_cores": 4}], + "scaling": {"min": 1, "max": 2} +}' +curl -s "${H[@]}" $API/models/qwen-small +curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2, "idle_timeout": 1800000000000}' + +# A team's capacity quota. +curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}' + +# Remove the model; a DELETE carries the content type too. +curl -s "${H[@]}" -X DELETE $API/models/qwen-small +``` + +Durations in bodies are integer nanoseconds, as Go encodes them: half an hour +is `1800000000000`. + +## The description + +The API is described in OpenAPI 3.1: + +- served by every control plane at `GET /api/v1/openapi.json` (with the + token, like the rest), as built into that binary, so it describes the + server you are talking to; +- committed as [admin-api.openapi.json](admin-api.openapi.json) for reading + without a cluster. + +```sh +curl -s "${H[@]}" $API/openapi.json > llmgw-admin.json +``` + +It lists every endpoint, its path and query parameters, and the schema of +each request and answer, with the meaning of each field. Load it into any +OpenAPI tool — Swagger UI, Redoc, an editor — or generate a client from it: + +```sh +docker run --rm -v "$PWD:/w" openapitools/openapi-generator-cli generate \ + -i /w/llmgw-admin.json -g python -o /w/llmgw-admin-client +``` + +### How it is kept true + +The document is generated, not written. `internal/control/adminapi/routes.go` +holds a table of every endpoint: its method and path, a summary, its query +parameters, and Go values of the types it reads and answers with. The +generator in `internal/docgen/openapi` reads those types by reflection, the +way `encoding/json` encodes them, and takes each field's description from its +comment in the source. + +Two tests keep it from drifting. One registers the API on a recording mux and +fails when an endpoint is served and not in the table, or in the table and not +served. The other regenerates the document and fails when it differs from the +committed one. After changing an endpoint or a type it carries: + +```sh +go generate ./internal/docgen +``` + +## What it does not cover + +- **Organizations, teams, projects, people and API keys.** These live in the + store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the + control plane, or in the web console's `/admin` pages on the client port + behind a session. The admin API reads the tree only where a quota or a + policy names a part of it. +- **The client-facing API** (`/v1/chat/completions` and the rest), which is + OpenAI's and Anthropic's, on `listen`, with API keys. +- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard + pages and `/debug/pprof`, which are not under `/api`. diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json new file mode 100644 index 0000000..ec1e245 --- /dev/null +++ b/docs/admin-api.openapi.json @@ -0,0 +1,6059 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "llmgw admin API", + "version": "v1", + "description": "The control plane's management API: what `llmgw ctl` and the console use.\n\nIt is served on the admin listener (admin_listen, 127.0.0.1:9090 by default), which stays on loopback unless admin_remote is set: reach it from elsewhere through an SSH tunnel. Every path under /api needs the admin token as a bearer token.\n\nThe listener also refuses, before the token is looked at: a Host header that is a name rather than an address or localhost; a request another site's page sent; and any method but GET and HEAD whose Content-Type is not application/json, even one with no body.\n\nDurations in bodies are integer nanoseconds, as Go encodes them. Generated from the route table in internal/control/adminapi; see docs/admin-api.md." + }, + "servers": [ + { + "url": "http://127.0.0.1:9090", + "description": "The admin listener at its default address, admin_listen." + } + ], + "security": [ + { + "adminToken": [] + } + ], + "tags": [ + { + "name": "audit" + }, + { + "name": "canaries" + }, + { + "name": "capacity" + }, + { + "name": "explain-placement" + }, + { + "name": "fabric-test" + }, + { + "name": "fabrics" + }, + { + "name": "guard" + }, + { + "name": "jobs" + }, + { + "name": "metrics" + }, + { + "name": "models" + }, + { + "name": "nodes" + }, + { + "name": "notify" + }, + { + "name": "nvlink" + }, + { + "name": "openapi" + }, + { + "name": "plan" + }, + { + "name": "quotas" + }, + { + "name": "replicas" + }, + { + "name": "requests" + }, + { + "name": "restart" + }, + { + "name": "summary" + }, + { + "name": "updates" + }, + { + "name": "usage" + }, + { + "name": "weights" + } + ], + "paths": { + "/api/v1/audit": { + "get": { + "operationId": "getAudit", + "summary": "Changes made, newest first", + "tags": [ + "audit" + ], + "parameters": [ + { + "name": "since", + "in": "query", + "description": "How far back: 90m, 24h, 7d; 7d when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The most events to answer with.", + "schema": { + "type": "integer" + } + }, + { + "name": "actor", + "in": "query", + "description": "Only changes by this actor.", + "schema": { + "type": "string" + } + }, + { + "name": "contains", + "in": "query", + "description": "Only events whose action or target contains this.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.AuditEvent" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/canaries": { + "get": { + "operationId": "getCanaries", + "summary": "Every canary, and how its two sides serve", + "tags": [ + "canaries" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.CanaryView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/capacity": { + "get": { + "operationId": "getCapacity", + "summary": "Every recorded measurement", + "tags": [ + "capacity" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/explain-placement": { + "get": { + "operationId": "getExplainPlacement", + "summary": "Why a model could not be placed", + "tags": [ + "explain-placement" + ], + "parameters": [ + { + "name": "model", + "in": "query", + "required": true, + "description": "The model's name or an alias.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabric-test": { + "post": { + "operationId": "postFabricTest", + "summary": "Measure bandwidth between two nodes, rail by rail", + "tags": [ + "fabric-test" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FabricTestRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FabricTestView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabrics": { + "get": { + "operationId": "getFabrics", + "summary": "The physical fabrics and the names nodes give them", + "tags": [ + "fabrics" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/fabrics.Report" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/fabrics/{id}/topology": { + "get": { + "operationId": "getFabricsIdTopology", + "summary": "One fabric's map, with the cluster's adapters live on it", + "tags": [ + "fabrics" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The physical fabric's ID, as GET /api/v1/fabrics lists it.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopologyView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/guard": { + "get": { + "operationId": "getGuard", + "summary": "The guardrail policies and where they are attached", + "tags": [ + "guard" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/guard/attachments": { + "post": { + "operationId": "postGuardAttachments", + "summary": "Attach a guardrail policy", + "tags": [ + "guard" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardAttachmentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/guard.Attachment" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteGuardAttachments", + "summary": "Detach a guardrail policy", + "tags": [ + "guard" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardAttachmentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/guard.Attachment" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/jobs": { + "get": { + "operationId": "getJobs", + "summary": "Every job since the control plane started", + "tags": [ + "jobs" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/jobs/{id}": { + "get": { + "operationId": "getJobsId", + "summary": "One job", + "tags": [ + "jobs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job's ID, as starting it answered.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/metrics/range": { + "post": { + "operationId": "postMetricsRange", + "summary": "Several Prometheus range queries at once", + "description": "Read from the Prometheus monitoring.prometheus names; configured is false without one.", + "tags": [ + "metrics" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetricsResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models": { + "get": { + "operationId": "getModels", + "summary": "Every model, with its replicas and state", + "tags": [ + "models" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "postModels", + "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.", + "tags": [ + "models" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelAdded" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/serve": { + "post": { + "operationId": "postModelsServe", + "summary": "Fetch a registry repository and serve it", + "tags": [ + "models" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Request" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}": { + "get": { + "operationId": "getModelsName", + "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.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteModelsName", + "summary": "Remove a model added at runtime", + "description": "A model declared in configuration is refused: it would come back on the next restart.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Removed" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/adapters": { + "post": { + "operationId": "postModelsNameAdapters", + "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.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdaptersRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdaptersRequest" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/canary": { + "post": { + "operationId": "postModelsNameCanary", + "summary": "Start a canary of a model's newer weights", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Canary" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "putModelsNameCanary", + "summary": "Change the share of requests a canary takes", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CanaryPercentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteModelsNameCanary", + "summary": "Take a canary out: its share first, then the candidate", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/capacity": { + "put": { + "operationId": "putModelsNameCapacity", + "summary": "Record what a replica was measured to serve", + "description": "Replaces an earlier measurement of the same model on the same profile.", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bench.Capacity" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bench.Capacity" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/class": { + "post": { + "operationId": "postModelsNameClass", + "summary": "Set a model's capacity class", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClassRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClassView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/headroom": { + "get": { + "operationId": "getModelsNameHeadroom", + "summary": "How many more replicas of a model fit now, and where", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The most to try placing; 16 when absent or past 64.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HeadroomView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/origin": { + "post": { + "operationId": "postModelsNameOrigin", + "summary": "Record the repository a model's weights came from", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OriginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OriginView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/owner": { + "post": { + "operationId": "postModelsNameOwner", + "summary": "Set the part of the tenancy tree a model's capacity counts against", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/promote": { + "post": { + "operationId": "postModelsNamePromote", + "summary": "Make a canary's weights the model's", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/resources": { + "post": { + "operationId": "postModelsNameResources", + "summary": "Set what a model's profiles reserve of a node", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/catalog.Model" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/retry": { + "post": { + "operationId": "postModelsNameRetry", + "summary": "Try a parked model again", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetryView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/rollback": { + "post": { + "operationId": "postModelsNameRollback", + "summary": "Go back to the weights the last update replaced", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Result" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/scale": { + "post": { + "operationId": "postModelsNameScale", + "summary": "Set how many replicas a model runs", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScaleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScaleResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/target": { + "get": { + "operationId": "getModelsNameTarget", + "summary": "How many replicas a throughput needs, and whether they fit", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "name": "tokens_per_sec", + "in": "query", + "required": true, + "description": "The output tokens a second wanted of the model.", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/models/{name}/update": { + "post": { + "operationId": "postModelsNameUpdate", + "summary": "Move a model to its repository's newer weights", + "tags": [ + "models" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The model's name in the catalog.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Job" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes": { + "get": { + "operationId": "getNodes", + "summary": "Every node, with its cards, load and fabrics", + "tags": [ + "nodes" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NodeView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}": { + "delete": { + "operationId": "deleteNodesId", + "summary": "Forget a node that has gone", + "description": "Only a node that is lost, or unreachable with nothing on it; its certificate is revoked.", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeRemoved" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/engines": { + "post": { + "operationId": "postNodesIdEngines", + "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.", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeEngineRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeEngineRequest" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/logs": { + "get": { + "operationId": "getNodesIdLogs", + "summary": "The last lines of a node's logs", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "How many lines; 300 when absent, at most 2000.", + "schema": { + "type": "integer" + } + }, + { + "name": "source", + "in": "query", + "description": "Which log: agent when absent, or an engine the node lists in sources.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/state": { + "post": { + "operationId": "postNodesIdState", + "summary": "Cordon, drain or ready a node", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeStateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeStateView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nodes/{id}/upgrade": { + "post": { + "operationId": "postNodesIdUpgrade", + "summary": "Have a node run a release", + "tags": [ + "nodes" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The node's ID, as `llmgw ctl nodes` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradeView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/notify/test": { + "post": { + "operationId": "postNotifyTest", + "summary": "Send a test notification to every webhook", + "tags": [ + "notify" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/notify.Result" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/nvlink": { + "get": { + "operationId": "getNvlink", + "summary": "The multi-node NVLink domains and what keeps nodes out of them", + "tags": [ + "nvlink" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/nvlink.Report" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "getOpenapiJson", + "summary": "This document", + "description": "The admin API in OpenAPI 3.1, generated from the route table and built into the binary.", + "tags": [ + "openapi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/plan": { + "get": { + "operationId": "getPlan", + "summary": "How a registry repository would be served here", + "tags": [ + "plan" + ], + "parameters": [ + { + "name": "repo", + "in": "query", + "required": true, + "description": "The repository, owner/name.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "gguf or safetensors; gguf when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "context", + "in": "query", + "description": "The context length to plan for, in tokens.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/modelserve.Plan" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/quotas": { + "get": { + "operationId": "getQuotas", + "summary": "Every capacity quota, with what its scope holds now", + "tags": [ + "quotas" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuotaView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "putQuotas", + "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.", + "tags": [ + "quotas" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaSet" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/quotas/{scope}": { + "delete": { + "operationId": "deleteQuotasScope", + "summary": "Remove a scope's capacity quota", + "tags": [ + "quotas" + ], + "parameters": [ + { + "name": "scope", + "in": "path", + "required": true, + "description": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Removed" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/replicas": { + "get": { + "operationId": "getReplicas", + "summary": "Every replica, with its shards", + "tags": [ + "replicas" + ], + "parameters": [ + { + "name": "model", + "in": "query", + "description": "Only this model's replicas.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReplicaView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}": { + "get": { + "operationId": "getRequestsId", + "summary": "What one request went through", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}/body": { + "get": { + "operationId": "getRequestsIdBody", + "summary": "A kept request and its answer", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/store.RequestBody" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/requests/{id}/replay": { + "post": { + "operationId": "postRequestsIdReplay", + "summary": "Send a kept request again, to any model", + "tags": [ + "requests" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The request's ID: the x-request-id a client was answered with.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/restart": { + "post": { + "operationId": "postRestart", + "summary": "Restart replicas, every replica of some models, or all", + "tags": [ + "restart" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: started, and running on.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/summary": { + "get": { + "operationId": "getSummary", + "summary": "The whole fleet in one answer", + "tags": [ + "summary" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Summary" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/updates": { + "get": { + "operationId": "getUpdates", + "summary": "Newer weights the registry has for models", + "tags": [ + "updates" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/updates/check": { + "post": { + "operationId": "postUpdatesCheck", + "summary": "Ask the registry now, about one model or every one", + "tags": [ + "updates" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/usage": { + "get": { + "operationId": "getUsage", + "summary": "What each group of the tenancy tree spent", + "tags": [ + "usage" + ], + "parameters": [ + { + "name": "by", + "in": "query", + "description": "org, team or project; team when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "since", + "in": "query", + "description": "A duration back from now, like 24h; 24h when absent.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScopeUsageView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/usage/export": { + "get": { + "operationId": "getUsageExport", + "summary": "Usage totals by period and dimension, as CSV or JSON", + "tags": [ + "usage" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "description": "A date (2026-09-01) or an RFC 3339 time; the first of this month when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "description": "A date or an RFC 3339 time; now when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "period", + "in": "query", + "description": "hour, day or month; day when absent.", + "schema": { + "type": "string" + } + }, + { + "name": "by", + "in": "query", + "description": "Dimensions, comma-separated: org, team, project, user, key, model.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "json for JSON; CSV otherwise.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/store.ExportRow" + } + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/weights/collect": { + "post": { + "operationId": "postWeightsCollect", + "summary": "Delete weights that no model uses", + "tags": [ + "weights" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectResult" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/weights/orphans": { + "get": { + "operationId": "getWeightsOrphans", + "summary": "Weights on nodes that no model uses", + "tags": [ + "weights" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrphanView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "securitySchemes": { + "adminToken": { + "type": "http", + "scheme": "bearer", + "description": "The admin token: the contents of /var/lib/llmgw/admin-token on the control plane (admin_token_file), readable by root and the server. Sent as Authorization: Bearer TOKEN." + } + }, + "parameters": { + "Actor": { + "name": "X-Llmgw-Actor", + "in": "header", + "required": false, + "description": "Who the change is recorded as made by in the audit log: letters, digits and ._-@, at most 64. unknown when absent.", + "schema": { + "type": "string" + } + } + }, + "responses": { + "Error": { + "description": "Refused: the error says why.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorView" + } + } + } + }, + "Unauthorized": { + "description": "The admin token is missing or wrong.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorView" + } + } + } + } + }, + "schemas": { + "AdaptersRequest": { + "type": "object", + "description": "AdaptersRequest replaces a model's LoRA adapters with these.", + "properties": { + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + } + } + } + }, + "CanaryPercentRequest": { + "type": "object", + "description": "CanaryPercentRequest changes the share of a model's requests its canary takes.", + "properties": { + "percent": { + "type": "integer" + } + } + }, + "CapacityView": { + "type": "object", + "description": "CapacityView is one stored measurement, with who recorded it.", + "properties": { + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "max_concurrency_at_slo": { + "type": "integer", + "description": "MaxConcurrencyAtSLO is the capacity: the highest concurrency that still met the target. Zero means even one request did not, which is a real result and not a missing one — it says this hardware cannot serve this model at this SLO at all." + }, + "throughput_tok_per_sec": { + "type": "number", + "description": "ThroughputTokPerSec and the latencies are taken at that level, not at the peak. Peak throughput arrives long after latency has become unusable, so reporting it would overstate what the hardware can serve." + }, + "ttft_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "tpot_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "suggested_target_waiting": { + "type": "integer", + "description": "SuggestedTargetWaiting and SuggestedKVCeiling are the queue depth and cache pressure seen while the SLO was still being met, which is what admission should treat as healthy for this model. Admission currently applies one global default to every model; these are the per-model numbers it should be using instead." + }, + "suggested_kv_ceiling": { + "type": "number" + }, + "slo": { + "$ref": "#/components/schemas/bench.SLO", + "description": "SLO is recorded because capacity is meaningless without it. The same replica has a different capacity against a 200ms first-token target than against a 2s one, and a number stored without the target it was measured against cannot be compared with anything." + }, + "target": { + "type": "string", + "description": "Target is the endpoint the load actually went to, kept so a profile measured against the gateway is never mistaken for one measured against a replica. Only the second says anything about the hardware." + }, + "measured_at": { + "type": "string", + "format": "date-time" + }, + "measured_by": { + "type": "string" + } + } + }, + "CheckRequest": { + "type": "object", + "description": "CheckRequest asks the registry about one model now, or every one.", + "properties": { + "model": { + "type": "string" + } + } + }, + "ClassRequest": { + "type": "object", + "description": "ClassRequest sets a model's capacity class: critical, standard or opportunistic, or empty to go back to what its scaling implies.", + "properties": { + "class": { + "type": "string" + } + } + }, + "ClassUse": { + "type": "object", + "description": "ClassUse is how much of one kind of card is in use.", + "properties": { + "class": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "used": { + "type": "integer" + }, + "unhealthy": { + "type": "integer", + "description": "Unhealthy GPUs are neither used nor available, and counting them as free is how a cluster looks like it has room it does not have." + } + } + }, + "ClassView": { + "type": "object", + "description": "ClassView is a model's capacity class now.", + "properties": { + "model": { + "type": "string" + }, + "class": { + "type": "string" + } + } + }, + "CollectRequest": { + "type": "object", + "description": "CollectRequest limits a sweep to one node. Empty means every node, which is the common case after removing a model from the catalog.", + "properties": { + "node": { + "type": "string" + } + } + }, + "CollectResult": { + "type": "object", + "description": "CollectResult is what was deleted on one node, or why nothing was.", + "properties": { + "node": { + "type": "string" + }, + "files": { + "type": "integer" + }, + "bytes": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string" + } + } + }, + "ErrorView": { + "type": "object", + "description": "ErrorView is every refusal: a 4xx or 5xx status with what went wrong.", + "properties": { + "error": { + "type": "string" + } + } + }, + "ExplainView": { + "type": "object", + "description": "ExplainView is why a model could not be placed.", + "properties": { + "model": { + "type": "string" + }, + "placed": { + "type": "boolean", + "description": "Placed is true when the model has replicas, in which case there is nothing to explain." + }, + "rejections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RejectionView" + }, + "description": "Rejections is empty when placement was never attempted, which is itself the answer: the model is satisfied, or the reconciler has not run yet." + } + } + }, + "FabricTestRequest": { + "type": "object", + "description": "FabricTestRequest is a bandwidth test between two nodes.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "fabric": { + "type": "string", + "description": "Fabric is the declared fabric to test over; empty is the one the two nodes share." + }, + "seconds": { + "type": "integer", + "description": "Seconds each rail is measured for, from 1 to 30." + } + } + }, + "FabricTestView": { + "type": "object", + "description": "FabricTestView is a bandwidth test between two nodes.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "rails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RailView" + } + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "string" + } + } + }, + "FabricUse": { + "type": "object", + "description": "FabricUse is capacity within one fabric.", + "properties": { + "fabric": { + "type": "string" + }, + "nodes": { + "type": "integer" + }, + "nodes_with_weights": { + "type": "integer", + "description": "ModelsPresent is how many nodes here hold weights at all, and Models how many distinct models they hold between them.\n\nPer fabric because that is the unit at which the answer is actionable. Fabrics are not connected to one another, so weights in one are not weights in another: a model seeded into a single fabric leaves the rest of the cluster unable to serve it without first moving tens of gigabytes over the management network, and nothing said so." + }, + "models_present": { + "type": "integer" + }, + "largest_free_block": { + "type": "integer", + "description": "LargestFreeBlock is the most nodes on this fabric that are entirely free, which is the number that decides whether a multi-node replica can land." + } + } + }, + "FetchView": { + "type": "object", + "description": "FetchView is a download in flight.", + "properties": { + "source": { + "type": "string" + }, + "bytes_done": { + "type": "integer", + "format": "int64" + }, + "bytes_total": { + "type": "integer", + "format": "int64", + "description": "BytesTotal is 0 when the server did not say how large the file is." + }, + "percent": { + "type": "integer", + "description": "Percent is absent rather than zero when the total is unknown: an invented percentage that stops moving is worse than a byte count that keeps rising." + } + } + }, + "GPUView": { + "type": "object", + "description": "GPUView is one card as a node's page shows it.", + "properties": { + "index": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "compute_capability": { + "type": "string", + "description": "ComputeCapability is what decides which engine builds run on the card: CUDA 13 builds need 7.5, so a V100 (7.0) runs only older ones." + }, + "numa": { + "type": "integer" + }, + "nvlink_peers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "nearest_nic": { + "type": "string", + "description": "NearestNIC is the adapter closest to the card, and how close." + }, + "nic_path": { + "type": "string" + }, + "fabric_state": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "bandwidth": { + "type": "string" + } + } + }, + "GuardAttachmentRequest": { + "type": "object", + "description": "GuardAttachmentRequest attaches a policy to a part of the tenancy tree, a key or a person, or with DELETE takes it off again.", + "properties": { + "target": { + "type": "string", + "description": "Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID after the colon), key:KEY-ID or user:USER-ID." + }, + "policy": { + "type": "string", + "description": "Policy is a policy's name in server.yaml's guard.policies." + } + } + }, + "GuardAttachmentView": { + "type": "object", + "description": "GuardAttachmentView is one attachment, with the name people know its scope by.", + "properties": { + "Scope": { + "type": "string" + }, + "ScopeID": { + "type": "string" + }, + "Policy": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "GuardView": { + "type": "object", + "description": "GuardView is the library and where its policies are attached.", + "properties": { + "policies": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "array", + "items": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GuardAttachmentView" + } + } + } + }, + "HeadroomView": { + "type": "object", + "description": "HeadroomView is how many more replicas of a model fit now, and where.", + "properties": { + "model": { + "type": "string" + }, + "current": { + "type": "integer", + "description": "Current is the replicas holding capacity: starting or ready." + }, + "more": { + "type": "integer", + "description": "More is how many more placement would find room for now." + }, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Nodes is where each of those would go." + }, + "limited": { + "type": "boolean", + "description": "Limited is set when More stopped at the limit asked rather than at the cluster's edge: there may be room for more." + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Blocked says why one more does not fit, a line per node considered." + }, + "tok_per_sec": { + "type": "number", + "description": "TokPerSec is what one more replica was measured to serve; 0 unmeasured." + }, + "by_preempting": { + "type": "integer", + "description": "ByPreempting is how many more would fit by stopping replicas this model's class may take; Preempts the models those belong to." + }, + "preempts": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "IBPortView": { + "type": "object", + "description": "IBPortView is one port as a page shows it.", + "properties": { + "hca": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "phys_state": { + "type": "string" + }, + "rate_gbps": { + "type": "integer" + }, + "link_layer": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "fabric": { + "type": "string", + "description": "Fabric is the declared fabric this port's adapter carries, if any." + }, + "physical": { + "type": "string", + "description": "Physical is the fabric the port is physically on: its subnet manager, or its RoCE network. SMError says why that is not known." + }, + "sm_error": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + }, + "description": "Errors are the error counters that are not zero, by name." + }, + "tx_bytes": { + "type": "integer", + "format": "int64" + }, + "rx_bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "IdleReplica": { + "type": "object", + "description": "IdleReplica is a replica holding hardware nothing is asking for.", + "properties": { + "id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "idle_seconds": { + "type": "integer", + "format": "int64" + }, + "gpus": { + "type": "integer" + } + } + }, + "Live": { + "type": "object", + "description": "Live is one of the cluster's adapters as its node last reported it.", + "properties": { + "node": { + "type": "string" + }, + "hca": { + "type": "string" + }, + "state": { + "type": "string" + }, + "phys_state": { + "type": "string" + }, + "gbps": { + "type": "integer" + }, + "tx_bps": { + "type": "number" + }, + "rx_bps": { + "type": "number" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + } + } + }, + "LoadView": { + "type": "object", + "description": "LoadView is a node's CPU and memory in use, at its last heartbeat.", + "properties": { + "cpu_percent": { + "type": "number" + }, + "cpus": { + "type": "integer" + }, + "load1": { + "type": "number" + }, + "load5": { + "type": "number" + }, + "load15": { + "type": "number" + }, + "memory_used_bytes": { + "type": "integer", + "format": "int64" + }, + "memory_total_bytes": { + "type": "integer", + "format": "int64" + }, + "memory_percent": { + "type": "number", + "description": "MemPercent is used over used plus available." + }, + "age_seconds": { + "type": "integer", + "format": "int64" + }, + "cpu_temp_c": { + "type": "number", + "description": "What the node's sensors say; zero is not measured." + }, + "cpu_power_w": { + "type": "number" + }, + "inlet_temp_c": { + "type": "number" + }, + "gpu_sensors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/inventory.GPUSensor" + } + } + } + }, + "LogsView": { + "type": "object", + "description": "LogsView is one of a node's logs, as the node sent it.", + "properties": { + "node": { + "type": "string" + }, + "source": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sources are every log the node can show: \"agent\", and one per engine running there." + }, + "error": { + "type": "string" + } + } + }, + "MetricsQuery": { + "type": "object", + "description": "MetricsQuery is one line set of a chart: a PromQL expression, and the label naming each line.", + "properties": { + "id": { + "type": "string" + }, + "expr": { + "type": "string" + }, + "by": { + "type": "string" + } + } + }, + "MetricsRequest": { + "type": "object", + "description": "MetricsRequest asks for several queries over one window at once.", + "properties": { + "window": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "step": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricsQuery" + } + } + } + }, + "MetricsResult": { + "type": "object", + "description": "MetricsResult is each query's lines, by id; Configured is false when no Prometheus is set, and Errors says which queries failed and why.", + "properties": { + "configured": { + "type": "boolean" + }, + "series": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/promq.Series" + } + } + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ModelAdded": { + "type": "object", + "description": "ModelAdded names the model added to the catalog.", + "properties": { + "added": { + "type": "string" + } + } + }, + "ModelTotals": { + "type": "object", + "description": "ModelTotals counts models by whether they have what they asked for.", + "properties": { + "total": { + "type": "integer" + }, + "satisfied": { + "type": "integer" + }, + "short": { + "type": "integer" + } + } + }, + "ModelView": { + "type": "object", + "description": "ModelView is a model as `ctl models` shows it.", + "properties": { + "name": { + "type": "string" + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary", + "description": "Canary is a share of its requests sent to a candidate; CanaryOf is set on the candidate, naming the model it is a canary of." + }, + "canary_of": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Description is what the model is for, when somebody said." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string" + }, + "min_replicas": { + "type": "integer" + }, + "max_replicas": { + "type": "integer" + }, + "ready_replicas": { + "type": "integer" + }, + "total_replicas": { + "type": "integer" + }, + "desired_replicas": { + "type": "integer", + "description": "Desired is how many it should have now: Min, or for an on-demand model one while it is being asked for and none otherwise." + }, + "failures": { + "type": "integer", + "description": "Failures counts replicas that failed to start in a row." + }, + "parked": { + "type": "boolean", + "description": "Parked is set once Failures reached the limit; the model is not retried until its definition changes or `ctl retry` is run. Reason is the last error." + }, + "reason": { + "type": "string" + }, + "unplaceable": { + "type": "boolean", + "description": "Unplaceable is set when it is short and the last attempt found no node." + }, + "fallbacks": { + "type": "array", + "items": { + "type": "string" + } + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "memory_gb": { + "type": "integer", + "description": "MemoryGB and CPUCores are what one replica reserves, from its first profile; MeasuredMemoryGB is the most one of its replicas uses now, 0 when none is measured." + }, + "cpu_cores": { + "type": "integer" + }, + "measured_memory_gb": { + "type": "number" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Warnings are what makes its reservation wrong: nothing declared where placement needs a figure, or less declared than it uses." + }, + "class": { + "type": "string", + "description": "Class is its capacity class; Preempted the replicas it lost to a higher class; HeldFor the model it is giving room to now, if any." + }, + "owner": { + "type": "string" + }, + "external": { + "type": "string", + "description": "External is where a model served outside the cluster goes, provider host and its name for the model; empty for one served here." + }, + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + }, + "description": "Adapters are the LoRA adapters it serves." + }, + "preempted": { + "type": "integer" + }, + "held_for": { + "type": "string" + }, + "origin": { + "$ref": "#/components/schemas/OriginView", + "description": "Origin is the registry repository its weights came from, and at which commit; Update what that repository had when it was last asked." + }, + "update": { + "$ref": "#/components/schemas/modelserve.UpdateInfo" + } + } + }, + "NodeEngineRequest": { + "type": "object", + "description": "NodeEngineRequest chooses one engine's build for a node.", + "properties": { + "engine": { + "type": "string" + }, + "build": { + "type": "string", + "description": "Build is \"auto\", a build such as \"cu129\", or \"none\"." + } + } + }, + "NodeRemoved": { + "type": "object", + "description": "NodeRemoved names the node forgotten. Removed is the string \"true\".", + "properties": { + "id": { + "type": "string" + }, + "removed": { + "type": "string" + } + } + }, + "NodeStateRequest": { + "type": "object", + "description": "NodeStateRequest cordons, drains or readies a node: ready (or uncordon), cordoned (or cordon), draining (or drain).", + "properties": { + "state": { + "type": "string" + } + } + }, + "NodeStateView": { + "type": "object", + "description": "NodeStateView is a node's state after the change.", + "properties": { + "id": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "NodeTotals": { + "type": "object", + "description": "NodeTotals counts nodes by the states that need different responses.", + "properties": { + "total": { + "type": "integer" + }, + "ready": { + "type": "integer" + }, + "cordoned": { + "type": "integer" + }, + "draining": { + "type": "integer" + }, + "unreachable": { + "type": "integer" + } + } + }, + "NodeView": { + "type": "object", + "description": "NodeView is a node as `ctl nodes` shows it.", + "properties": { + "id": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "mgmt_addr": { + "type": "string" + }, + "state": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "gpu_total": { + "type": "integer" + }, + "gpu_free": { + "type": "integer" + }, + "memory_gb": { + "type": "integer", + "description": "MemoryGB is the machine's RAM, 0 when the agent did not report it. It is what decides whether a CPU profile can be placed here, so it belongs where someone looks when a model will not schedule." + }, + "cpu_count": { + "type": "integer", + "description": "CPUCount is the machine's CPUs, 0 when the agent did not report them." + }, + "engines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Engines are the inference engines installed here, as the agent found them. Absent means the agent is older than the probe and said nothing, which is not the same as having none — placement treats it that way too, or a control plane upgrade would empty a fleet." + }, + "provision": { + "type": "array", + "items": { + "$ref": "#/components/schemas/inventory.EngineInstall" + }, + "description": "Provision is where installing each wanted engine stands. An engine is in Engines only once its installation here is ready." + }, + "engine_builds": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "EngineBuilds are the builds chosen for this node, by engine: \"auto\" (its hardware decides), a build (\"cu129\"), or \"none\". Only on a node with GPUs, the only kind with a choice to make." + }, + "os": { + "type": "string", + "description": "OS is the operating system and architecture, as the agent read them." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Warnings are facts about the machine that make it slow or unusable without making it unhealthy: a VM with no vector instructions runs every model, at a fifth of the speed." + }, + "models_on_disk": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Models are the weights this node can see. Absent means it was not asked to look, which is not the same as holding none." + }, + "disk_free_gb": { + "type": "integer", + "description": "DiskFreeGB and DiskTotalGB are the filesystems holding those weights, summed. Zero means the agent did not report any, which an older agent and a node with no model directories both do — so it reads as unknown rather than as a full disk.\n\nSummed because an operator asking \"can this node take another model\" wants one number; the per-filesystem detail is in the node's own record for anyone who needs it. Shared filesystems are left out of the sum: the space on a filer is not this node's to spend, and adding it would let a fleet of twenty nodes each believe it has the same free terabyte." + }, + "disk_total_gb": { + "type": "integer" + }, + "gpu_classes": { + "type": "array", + "items": { + "type": "string" + } + }, + "fabrics": { + "type": "array", + "items": { + "type": "string" + } + }, + "unhealthy_gpus": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Unhealthy lists GPUs kept out of placement and why, which is usually the answer when a node looks idle but nothing will schedule onto it." + }, + "last_seen_ago": { + "type": "string" + }, + "agent_version": { + "type": "string" + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Features are the requests the agent answers beyond the usual: \"fabric-test\". Absent is an agent too old to say." + }, + "replicas": { + "type": "integer" + }, + "load": { + "$ref": "#/components/schemas/LoadView", + "description": "Load is how busy the machine is, absent when its agent has not measured it lately — which is unknown, not idle." + }, + "ib_ports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IBPortView" + }, + "description": "IBPorts are the node's InfiniBand and RoCE ports at the last heartbeat." + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GPUView" + }, + "description": "GPUs are the cards, with how they are wired: NVLink peers, the adapter nearest each, and the NVLink domain each has joined." + }, + "nvlink_domain": { + "type": "string", + "description": "NVLinkDomain is the multi-node domain every card has joined, if any." + }, + "imex": { + "type": "string" + }, + "imex_channels": { + "type": "integer" + } + } + }, + "OriginRequest": { + "type": "object", + "description": "OriginRequest names the repository a model's weights came from.", + "properties": { + "repo": { + "type": "string" + } + } + }, + "OriginView": { + "type": "object", + "description": "OriginView is where a model's weights were published.", + "properties": { + "repo": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "previous": { + "type": "string", + "description": "Previous is the commit the last update replaced, which it can go back to." + } + } + }, + "OrphanView": { + "type": "object", + "description": "OrphanView is cached weights on one node that no catalog model accounts for.", + "properties": { + "node": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "bytes": { + "type": "integer", + "format": "int64", + "description": "Bytes is what deleting them would free, as far as the node reported. A file whose size was not reported counts as zero rather than being left out, so the list and the number always describe the same set." + } + } + }, + "OwnerRequest": { + "type": "object", + "description": "OwnerRequest sets the part of the tenancy tree a model's capacity counts against: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or their IDs. Empty makes the model shared.", + "properties": { + "owner": { + "type": "string" + } + } + }, + "OwnerView": { + "type": "object", + "description": "OwnerView is a model's owner now, as scope:ID.", + "properties": { + "model": { + "type": "string" + }, + "owner": { + "type": "string" + } + } + }, + "ParkedModel": { + "type": "object", + "description": "ParkedModel is a model no longer retried, and why.", + "properties": { + "model": { + "type": "string" + }, + "failures": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, + "QuotaRequest": { + "type": "object", + "description": "QuotaRequest sets a scope's quota. Scope may be written with an ID or a name: org:Acme, team:Acme/ml, project:Acme/ml/training.", + "properties": { + "scope": { + "type": "string" + }, + "guarantee": { + "$ref": "#/components/schemas/store.Resources" + }, + "limit": { + "$ref": "#/components/schemas/store.Resources" + } + } + }, + "QuotaSet": { + "type": "object", + "description": "QuotaSet is the scope a quota was set on, as org:ID, team:ID or project:ID whatever name it was asked by.", + "properties": { + "scope": { + "type": "string" + } + } + }, + "QuotaView": { + "type": "object", + "description": "QuotaView is one scope's quota with what it holds now.", + "properties": { + "scope": { + "type": "string", + "description": "Scope is team:ID; Name is how people know it, \"Acme / ml\"." + }, + "name": { + "type": "string" + }, + "guarantee": { + "$ref": "#/components/schemas/store.Resources" + }, + "limit": { + "$ref": "#/components/schemas/store.Resources" + }, + "holds": { + "$ref": "#/components/schemas/quota.Amount" + }, + "updated_by": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "RailView": { + "type": "object", + "description": "RailView is one rail's result.", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "string" + }, + "rate_gbps": { + "type": "integer" + }, + "gbps": { + "type": "number" + }, + "verdict": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "RejectionView": { + "type": "object", + "description": "RejectionView is one rejected candidate.", + "properties": { + "profile": { + "type": "string" + }, + "pool": { + "type": "string" + }, + "node": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "Removed": { + "type": "object", + "description": "Removed names what was taken away: a model, or a quota's scope.", + "properties": { + "removed": { + "type": "string" + } + } + }, + "ReplayRequest": { + "type": "object", + "description": "ReplayRequest names the model a kept request is sent to again.", + "properties": { + "model": { + "type": "string" + } + } + }, + "ReplayView": { + "type": "object", + "description": "ReplayView is a kept request sent again, beside what it got the first time.", + "properties": { + "original": { + "$ref": "#/components/schemas/store.RequestBody" + }, + "model": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "body": { + "description": "Any JSON value." + }, + "took_ms": { + "type": "integer", + "format": "int64" + } + } + }, + "ReplicaTotal": { + "type": "object", + "description": "ReplicaTotal counts replicas by state.", + "properties": { + "ready": { + "type": "integer" + }, + "starting": { + "type": "integer" + }, + "draining": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + } + }, + "ReplicaView": { + "type": "object", + "description": "ReplicaView is a replica as `ctl replicas` shows it.", + "properties": { + "id": { + "type": "string" + }, + "engine_key": { + "type": "string", + "description": "EngineKey is the key this replica's engine demands, for `ctl bench` and anyone else holding the admin listener who needs to reach an engine directly. The client-facing pages never render it." + }, + "restart": { + "type": "string", + "description": "Restart is \"queued\" or \"replacing\" while an operator's restart of this replica is under way." + }, + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "stale": { + "type": "boolean", + "description": "Stale is true when the profile has been edited since this replica was placed, so it is running a definition the catalog no longer holds.\n\nReported rather than acted on. Moving a replica that is serving costs more than the imperfection, and the operator who made the edit is the one who should decide whether it is worth a restart." + }, + "state": { + "type": "string" + }, + "pool": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "age_seconds": { + "type": "integer", + "format": "int64" + }, + "idle_seconds": { + "type": "integer", + "format": "int64", + "description": "IdleSec is how long since a request was routed here. Negative means the router has not reported any use of this replica, which is not the same as idle — it is what a replica looks like before anything has asked for it, and after a router restart." + }, + "error": { + "type": "string" + }, + "shards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardView" + } + } + } + }, + "RequestView": { + "type": "object", + "description": "RequestView is what one request went through.", + "properties": { + "request_id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "key_id": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "model": { + "type": "string" + }, + "served_model": { + "type": "string" + }, + "replica": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "latency_ms": { + "type": "integer", + "format": "int64" + }, + "queue_ms": { + "type": "integer", + "format": "int64" + }, + "ttft_ms": { + "type": "integer", + "format": "int64" + }, + "prompt_tokens": { + "type": "integer", + "format": "int64" + }, + "completion_tokens": { + "type": "integer", + "format": "int64" + }, + "cost_micros": { + "type": "integer", + "format": "int64" + }, + "guard_action": { + "type": "string", + "description": "What the guardrails did, by name." + }, + "guard_policy": { + "type": "string" + }, + "guard_category": { + "type": "string" + }, + "guard_ms": { + "type": "integer", + "format": "int64" + }, + "end_user": { + "type": "string", + "description": "What the client said about it: OpenAI's user field, and its tags." + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ResourcesRequest": { + "type": "object", + "description": "ResourcesRequest sets what a model's profiles reserve of a node.", + "properties": { + "profile": { + "type": "string", + "description": "Profile is one profile's name; empty is every profile." + }, + "memory_gb": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer" + } + } + }, + "RestartRequest": { + "type": "object", + "description": "RestartRequest names what to restart: replicas by id, every replica of some models, or — All — every ready replica in the cluster.", + "properties": { + "replicas": { + "type": "array", + "items": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "all": { + "type": "boolean" + }, + "in_place": { + "type": "boolean", + "description": "InPlace stops each replica before starting its replacement, rather than starting the replacement beside it first." + } + } + }, + "RestartResult": { + "type": "object", + "description": "RestartResult lists the replicas queued.", + "properties": { + "queued": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Result": { + "type": "object", + "description": "Result is what a change that has no other answer says it did.", + "properties": { + "result": { + "type": "string" + } + } + }, + "RetryView": { + "type": "object", + "description": "RetryView says whether the model had been parked, given up on after failing to start, before it was retried.", + "properties": { + "was_parked": { + "type": "boolean" + } + } + }, + "ScaleRequest": { + "type": "object", + "description": "ScaleRequest is how many replicas a model should run.", + "properties": { + "min": { + "type": "integer" + }, + "max": { + "type": "integer", + "description": "Max omitted follows Min, which is what the catalog does for a model that declares only a minimum." + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + } + } + }, + "ScaleResponse": { + "type": "object", + "description": "ScaleResponse reports the scaling that is now in force.\n\nWhat is in force rather than what was asked for: the catalog fills in a max that was left out, and a caller that echoed its own request would print a number the cluster is not using.", + "properties": { + "model": { + "type": "string" + }, + "min": { + "type": "integer" + }, + "max": { + "type": "integer" + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "on_demand": { + "type": "boolean" + } + } + }, + "ScopeUsageView": { + "type": "object", + "description": "ScopeUsageView is one group's spend, with its quota when it has one.", + "properties": { + "scope": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "keys": { + "type": "integer", + "format": "int64" + }, + "requests": { + "type": "integer", + "format": "int64" + }, + "prompt_tokens": { + "type": "integer", + "format": "int64" + }, + "completion_tokens": { + "type": "integer", + "format": "int64" + }, + "cost_micros": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64" + }, + "last_at": { + "type": "string", + "format": "date-time" + }, + "budget_micros": { + "type": "integer", + "format": "int64", + "description": "Budget is what this level sets, absent when it sets none. A group bounded only by its parent has no budget of its own, and reporting the parent's against each child would read as though each had that much." + }, + "budget_period": { + "type": "string" + }, + "fraction": { + "type": "number", + "description": "Fraction is spend over budget, absent for the same reason." + } + } + }, + "ShardView": { + "type": "object", + "description": "ShardView is one shard of a replica.", + "properties": { + "id": { + "type": "string" + }, + "node": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "gpus": { + "type": "array", + "items": { + "type": "integer" + } + }, + "state": { + "type": "string" + }, + "error": { + "type": "string" + }, + "fetch": { + "$ref": "#/components/schemas/FetchView", + "description": "Fetch is the download this shard is waiting on, absent when it is not waiting on one. It is what tells a replica pulling a hundred gigabytes apart from one that is wedged — which look identical without it." + }, + "cpu_cores": { + "type": "number", + "description": "CPUCores and MemoryBytes are the engine's use of its node, from the node's last heartbeat; zero when not measured." + }, + "memory_bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "StoreMember": { + "type": "object", + "description": "StoreMember is one member of a replicated store, as its Patroni reports it.", + "properties": { + "name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "role": { + "type": "string" + }, + "state": { + "type": "string" + }, + "lag": { + "type": "integer", + "format": "int64", + "description": "Lag is bytes behind the leader; -1 unknown, 0 for the leader." + } + } + }, + "Summary": { + "type": "object", + "description": "Summary is the whole fleet in one answer.\n\nA per-node listing is the right tool for four nodes and the wrong one for two hundred: the questions that matter at that size — how full am I, what is broken, what is holding cards it is not using — are aggregates, and reading them off a long table is a chore that gets skipped. Everything here is derived from the same state the tables show, so it can never disagree with them; it is a different question asked of the same facts.", + "properties": { + "nodes": { + "$ref": "#/components/schemas/NodeTotals" + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClassUse" + } + }, + "fabrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FabricUse" + }, + "description": "Fabrics matter because a multi-node replica may never span two of them, so free capacity in the wrong fabric is not capacity for the model that needs it." + }, + "replicas": { + "$ref": "#/components/schemas/ReplicaTotal" + }, + "models": { + "$ref": "#/components/schemas/ModelTotals" + }, + "unplaceable": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Unplaceable names models the scheduler currently cannot satisfy. This is the line that turns \"something is wrong\" into a place to look." + }, + "parked": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParkedModel" + }, + "description": "Parked are models that failed every time they started and are no longer retried, with the last error." + }, + "idle_replicas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdleReplica" + }, + "description": "IdleReplicas are serving replicas nothing has asked for in a while, which on a fleet with more models than GPUs is the first place to find room." + }, + "store": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoreMember" + }, + "description": "Store is the members of the store's cluster when it is replicated, and StoreError why it could not be read; both empty for a single database." + }, + "store_error": { + "type": "string" + } + } + }, + "TargetView": { + "type": "object", + "description": "TargetView is how many replicas a model needs to serve a throughput, from what one replica was measured to serve, and whether the cluster has room.", + "properties": { + "model": { + "type": "string" + }, + "tok_per_sec": { + "type": "number", + "description": "TokPerSec is what was asked for; PerReplica what one replica was measured to serve at its SLO, on Profile." + }, + "per_replica_tok_per_sec": { + "type": "number" + }, + "profile": { + "type": "string" + }, + "needed": { + "type": "integer", + "description": "Needed replicas in all; Current hold capacity now; More is how many of Needed are not running, and Room how many more fit." + }, + "current": { + "type": "integer" + }, + "more": { + "type": "integer" + }, + "room": { + "type": "integer" + }, + "fits": { + "type": "boolean" + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + } + }, + "preempts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Preempts is whose replicas would be stopped to make the room, when free room alone is not enough; Room counts that room too." + } + } + }, + "TopologyView": { + "type": "object", + "description": "TopologyView is one fabric's map, with the cluster's own adapters on it live: their state, rate and traffic from the last heartbeat.", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "declared": { + "type": "array", + "items": { + "type": "string" + } + }, + "scanned_at": { + "type": "string", + "format": "date-time" + }, + "scanned_by": { + "type": "string" + }, + "took_ms": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string" + }, + "map": { + "$ref": "#/components/schemas/topology.View" + }, + "live": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Live" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Problem" + }, + "description": "Problems are the fabric's own, from the analysis of its identity and subnet managers." + }, + "sm": { + "$ref": "#/components/schemas/fabrics.SM" + }, + "sms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.SM" + } + } + } + }, + "UpgradeRequest": { + "type": "object", + "description": "UpgradeRequest names the release a node should run.", + "properties": { + "version": { + "type": "string" + } + } + }, + "UpgradeView": { + "type": "object", + "description": "UpgradeView is the release a node was told to run.", + "properties": { + "node": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "bench.Capacity": { + "type": "object", + "description": "Capacity is what one replica was measured to serve, kept so the measurement outlives the terminal it was printed in.\n\nThe conclusion of a run, not the run. The ramp itself — every concurrency level, its latencies, its errors — is worth reading once, while deciding whether the run was sound; it is not worth carrying around afterwards, and storing it would grow without bound as a model is re-benchmarked. What a later reader needs is the number and enough provenance to judge whether it is still true.", + "properties": { + "model": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "max_concurrency_at_slo": { + "type": "integer", + "description": "MaxConcurrencyAtSLO is the capacity: the highest concurrency that still met the target. Zero means even one request did not, which is a real result and not a missing one — it says this hardware cannot serve this model at this SLO at all." + }, + "throughput_tok_per_sec": { + "type": "number", + "description": "ThroughputTokPerSec and the latencies are taken at that level, not at the peak. Peak throughput arrives long after latency has become unusable, so reporting it would overstate what the hardware can serve." + }, + "ttft_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "tpot_p99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "suggested_target_waiting": { + "type": "integer", + "description": "SuggestedTargetWaiting and SuggestedKVCeiling are the queue depth and cache pressure seen while the SLO was still being met, which is what admission should treat as healthy for this model. Admission currently applies one global default to every model; these are the per-model numbers it should be using instead." + }, + "suggested_kv_ceiling": { + "type": "number" + }, + "slo": { + "$ref": "#/components/schemas/bench.SLO", + "description": "SLO is recorded because capacity is meaningless without it. The same replica has a different capacity against a 200ms first-token target than against a 2s one, and a number stored without the target it was measured against cannot be compared with anything." + }, + "target": { + "type": "string", + "description": "Target is the endpoint the load actually went to, kept so a profile measured against the gateway is never mistaken for one measured against a replica. Only the second says anything about the hardware." + }, + "measured_at": { + "type": "string", + "format": "date-time" + } + } + }, + "bench.SLO": { + "type": "object", + "description": "SLO is the latency target capacity is measured against.", + "properties": { + "TTFTp99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + }, + "TPOTp99": { + "type": "integer", + "format": "int64", + "description": "A duration, in nanoseconds." + } + } + }, + "canary.Side": { + "type": "object", + "description": "Side is what one side of a canary served since it began.", + "properties": { + "requests": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64", + "description": "Errors are answers of 500 and up: the engine's failures, not the client's mistakes." + }, + "ttft_ms": { + "type": "number", + "description": "TTFT is over streamed requests, Latency over the rest, both means in milliseconds with how many they are over." + }, + "ttft_count": { + "type": "integer", + "format": "int64" + }, + "latency_ms": { + "type": "number" + }, + "latency_count": { + "type": "integer", + "format": "int64" + } + } + }, + "canary.Stat": { + "type": "object", + "description": "Stat compares the two sides of one model's canary.", + "properties": { + "canary": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "stable": { + "$ref": "#/components/schemas/canary.Side" + }, + "candidate": { + "$ref": "#/components/schemas/canary.Side" + } + } + }, + "catalog.Adapter": { + "type": "object", + "description": "Adapter is a LoRA adapter served on a model's replicas.", + "properties": { + "name": { + "type": "string", + "description": "Name is what clients ask for, like a model name." + }, + "source": { + "type": "string", + "description": "Source is the adapter file (GGUF for llama.cpp), fetched by each node as the model's weights are." + }, + "scale": { + "type": "number", + "description": "Scale is how strongly it is applied; zero takes 1." + } + } + }, + "catalog.Canary": { + "type": "object", + "description": "Canary is a share of a model's requests sent to a candidate, and when to give up on it.", + "properties": { + "model": { + "type": "string", + "description": "Model is the candidate: a catalog model, usually this one at newer weights, which clients cannot name themselves." + }, + "percent": { + "type": "integer", + "description": "Percent of requests go to it, 0 to 100." + }, + "min_requests": { + "type": "integer", + "description": "The candidate is taken out — rolled back — when, over at least MinRequests of its own, its error rate is MaxErrorIncrease above this model's (0.05 is five points), or its mean time to the first token is MaxTTFTRatio times this model's. Zero is 50, 0.05 and 1.5." + }, + "max_error_increase": { + "type": "number" + }, + "max_ttft_ratio": { + "type": "number" + } + } + }, + "catalog.Capabilities": { + "type": "object", + "description": "Capabilities describes what a client may ask a model for.\n\nDeclared by the operator rather than discovered, because there is no way to ask an engine what its model can do. That makes these claims, and a wrong claim is worse than none — which is why `ctl engine-check` verifies tool calling against a running engine rather than trusting this.\n\nThe reason it exists: the gateway forwards a tools array to any model, and a model that cannot use them answers in prose. Every OpenAI-compatible client then behaves as though the model is broken, and the only way to find out was to try it. That cost an afternoon.", + "properties": { + "tools": { + "type": "boolean" + }, + "vision": { + "type": "boolean" + }, + "embeddings": { + "type": "boolean" + } + } + }, + "catalog.External": { + "type": "object", + "description": "External is a provider outside the cluster that serves a model.", + "properties": { + "url": { + "type": "string", + "description": "URL is the provider's base address, without /v1: https://api.openai.com." + }, + "model": { + "type": "string", + "description": "Model is what the provider calls it; requests are rewritten to it." + }, + "api_key_file": { + "type": "string", + "description": "APIKeyFile holds the provider's key, on the control plane. A file rather than the key itself, so the key is in neither the catalog, the store nor the audit log; empty for a provider that asks for none." + } + } + }, + "catalog.GuardModel": { + "type": "object", + "description": "GuardModel is how a judge model's answers are read.", + "properties": { + "format": { + "type": "string", + "description": "Format is llamaguard3 (Meta's Llama Guard 3: \"safe\", or \"unsafe\" and the categories), shieldgemma (Google's ShieldGemma: Yes or No), or json ({\"safe\": false, \"categories\": [\"...\"]})." + }, + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Categories the model is asked about, for shieldgemma — each a policy sentence — and the names reported for llamaguard3's S1, S2, …." + } + } + }, + "catalog.Model": { + "type": "object", + "description": "Model is a catalog entry as clients see it.", + "properties": { + "name": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "description": "Description is what this model is for, in a line.\n\nWritten by whoever added it, because nobody else can say it. A registry offers a pipeline tag and a wall of README, and neither answers the question a person picking from a list is asking: which of these nine names should I send this request to.\n\nIt reaches clients through /v1/models, which is the only view most of them ever see. A gateway serving nine models by name alone makes that choice by guesswork." + }, + "source": { + "type": "string", + "description": "Source is the weights path or a HuggingFace id." + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Profile" + } + }, + "scaling": { + "$ref": "#/components/schemas/catalog.Scaling" + }, + "class": { + "type": "string", + "description": "Class is how much this model's capacity is worth against another's, when there is not enough for both: critical, standard or opportunistic. Empty is standard, or opportunistic for a model loaded on demand. See EffectiveClass." + }, + "owner": { + "type": "string", + "description": "Owner is the part of the tenancy tree whose capacity quota this model counts against, written org:ID, team:ID or project:ID. Empty is shared: held by the cluster, counted against nobody." + }, + "external": { + "$ref": "#/components/schemas/catalog.External", + "description": "External is a model served by someone else — an OpenAI-compatible API outside the cluster — rather than by replicas placed here. Such a model has no profiles and nothing is placed for it; the router sends its requests to the provider. Most useful named in another model's fallbacks: used only when the cluster has no capacity left. Its prompts leave the cluster, so which keys may name it is worth an allowlist." + }, + "adapters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.Adapter" + }, + "description": "Adapters are LoRA adapters served on this model's replicas, each asked for by its own name as though it were a model. Every replica loads every adapter, applying one only to a request that names it." + }, + "fallbacks": { + "type": "array", + "items": { + "type": "string" + } + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary", + "description": "Canary sends a share of this model's requests to another catalog model — the same model at new weights — to compare the two before moving to it (`llmgw ctl model canary`)." + }, + "routing_profile": { + "type": "string", + "description": "RoutingProfile selects how the router scores endpoints for this model." + }, + "slo": { + "$ref": "#/components/schemas/catalog.SLO" + }, + "context_length": { + "type": "integer", + "description": "ContextLength is how many tokens one request may use, prompt and completion together, and MaxOutput bounds the completion alone. Zero means unknown, which is what every model was until now.\n\nThe gateway knew neither, so an over-long prompt was not refused: it was admitted, given a replica, and failed at the engine, and a client sizing its prompts from /v1/models had nothing to read. Worse, the number is not the one an operator writes in the engine's arguments — llama.cpp divides --ctx-size across its parallel slots, so a pool of 8192 across four slots is 2048 a request, and nothing said so." + }, + "max_output": { + "type": "integer" + }, + "capabilities": { + "$ref": "#/components/schemas/catalog.Capabilities", + "description": "Capabilities is what this model can do beyond plain completion." + }, + "role": { + "type": "string", + "description": "Role \"guard\" is a model that judges the gateway's traffic for the guardrails rather than serving clients: hidden from /v1/models and the chat page, refused to any key that names it, critical by default, and always running. Guard says how its answers are read." + }, + "guard": { + "$ref": "#/components/schemas/catalog.GuardModel" + }, + "origin": { + "$ref": "#/components/schemas/catalog.Origin", + "description": "Origin is the registry repository the weights came from, at which commit, and each file's checksum there. Written when a model is served from the registry; for one that was not, worked out from the mirror's record of where it fetched the file. It is what lets the control plane say that the repository has published different weights since, and move the model to them." + } + } + }, + "catalog.Origin": { + "type": "object", + "description": "Origin is where a model's weights were published.", + "properties": { + "repo": { + "type": "string", + "description": "Repo is the repository, owner/name." + }, + "revision": { + "type": "string", + "description": "Revision is the commit the files were fetched at." + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.OriginFile" + }, + "description": "Files are the weights fetched from it, with what the registry published as each one's sha256. A new commit that leaves them alone — a README edited — is not an update." + }, + "previous": { + "$ref": "#/components/schemas/catalog.PreviousOrigin", + "description": "Previous is what the last update replaced, kept so it can be gone back to while its files are still on the mirror." + } + } + }, + "catalog.OriginFile": { + "type": "object", + "description": "OriginFile is one weights file of an origin.", + "properties": { + "path": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "bytes": { + "type": "integer", + "format": "int64" + } + } + }, + "catalog.PreviousOrigin": { + "type": "object", + "description": "PreviousOrigin is a model's source before an update, and where it came from.", + "properties": { + "source": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.OriginFile" + } + } + } + }, + "catalog.Profile": { + "type": "object", + "description": "Profile is how a model runs on one GPU class.", + "properties": { + "name": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "gpu_class": { + "type": "string" + }, + "tp": { + "type": "integer" + }, + "pp": { + "type": "integer" + }, + "gpus_per_node": { + "type": "integer", + "description": "GPUsPerNode defaults to TP for a single-node profile." + }, + "nodes": { + "type": "integer", + "description": "Nodes above 1 makes this a multi-node profile, which requires a fabric." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "min_memory_gb": { + "type": "integer", + "description": "MinMemoryGB is the host RAM this profile needs to run.\n\nThe number that matters is the model's working set: the weights, plus the KV cache for the whole context pool, plus the engine's own overhead. For a GPU profile that lives in the cards and this is small; for a CPU profile it is the whole thing, and getting it wrong means an OOM on a node that reported itself healthy.\n\nZero means unconstrained, which is every profile that existed before this. A node that does not report its memory is not excluded either — an older agent must not have its nodes quietly emptied by an upgrade of the control plane." + }, + "cpu_cores": { + "type": "integer", + "description": "CPUCores is the cores a replica of a profile with no GPU is given: reserved out of its node's CPUs by placement, as memory is, and what its engine computes on (llama.cpp's --threads).\n\nReserved because an engine takes every core it is told about, and two on one node that each think they have all of them spin against each other: on an 8-vCPU node, two 7B replicas fell from 10 tokens a second to under one each. Zero reserves nothing and leaves the thread count to the node, which is every profile from before this." + }, + "min_disk_gb": { + "type": "integer", + "description": "MinDiskGB is the free space a node needs before it can hold these weights.\n\nOnly checked when the node does not already have them: a node holding the file needs no room for it, and requiring the space twice would keep a model off the one node best placed to run it.\n\nDeclared rather than derived, because the size of a model that has never been fetched anywhere is not knowable from here — and a wrong guess fails late, halfway through a download, which is the failure this exists to prevent. Zero means unconstrained, and a node that reports no storage is not excluded: unknown is not full." + }, + "node_selector": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "NodeSelector restricts this profile to nodes carrying every one of these labels, with these values.\n\nA CPU profile otherwise fits everywhere, because there is no card to be free — which is right for a 0.5B model and wrong for a 14B one. Nothing in the inventory says how much memory a node has, so the operator says which nodes are meant for a model instead of the scheduler inferring it. Agents have reported labels since the beginning; this is what reads them." + }, + "weight": { + "type": "number", + "description": "Weight is the router's capacity weight. It should come from ctl bench rather than a guess (plan §6.2.1)." + }, + "priority": { + "type": "integer", + "description": "Priority orders profiles during placement; highest that fits wins." + }, + "startup_timeout": { + "type": "integer", + "format": "int64", + "description": "StartupTimeout bounds model loading. A 70B model across two nodes takes tens of minutes, so this is per profile rather than global.\n\nA duration, in nanoseconds." + } + } + }, + "catalog.SLO": { + "type": "object", + "description": "SLO is the latency target a model is expected to meet. `ctl bench` measures capacity against it (plan §6.2.1), and admission holds the lower bands back while the model's recent p99 is past it.", + "properties": { + "TTFTp99": { + "type": "integer", + "format": "int64", + "description": "TTFTp99 is the time to the first token 99 requests in 100 are within.\n\nA duration, in nanoseconds." + }, + "TPOTp99": { + "type": "integer", + "format": "int64", + "description": "TPOTp99 is the time per output token, after the first, 99 streamed requests in 100 are within.\n\nA duration, in nanoseconds." + } + } + }, + "catalog.ScaleWindow": { + "type": "object", + "description": "ScaleWindow is a part of the week with its own min and max.", + "properties": { + "days": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Days are mon … sun, or weekdays, weekends; empty is every day." + }, + "from": { + "type": "string", + "description": "From and To are HH:MM; a window whose To is before its From runs past midnight." + }, + "to": { + "type": "string" + }, + "min": { + "type": "integer" + }, + "max": { + "type": "integer", + "description": "Max, when set, is the window's ceiling for load scaling; zero keeps the model's own." + } + } + }, + "catalog.Scaling": { + "type": "object", + "description": "Scaling is how many replicas a model should have.", + "properties": { + "min": { + "type": "integer" + }, + "max": { + "type": "integer" + }, + "idle_timeout": { + "type": "integer", + "format": "int64", + "description": "IdleTimeout is how long an on-demand model may go unasked-for before its replicas are stopped. Zero takes the cluster default.\n\nMinutes rather than seconds, always. Stopping a replica throws away the KV cache prefix routing spent its effort filling, and a model that flaps in and out loses that entirely — so the saving has to be worth more than the cache, which it only is over a real idle period.\n\nA duration, in nanoseconds." + }, + "schedule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/catalog.ScaleWindow" + }, + "description": "Schedule changes min and max by the time of day, in the control plane's control.timezone: more replicas kept through working hours, fewer at night, an on-demand model kept loaded while people are at work. The first window that holds now wins; outside them all, Min and Max apply." + }, + "prewarm": { + "type": "integer", + "format": "int64", + "description": "Prewarm starts a window this long before it opens, so its replicas have loaded by then: loading a model is minutes, and the people who arrive at nine are the reason for the window.\n\nA duration, in nanoseconds." + } + } + }, + "fabrics.Change": { + "type": "object", + "description": "Change is a fabric's master subnet manager changing, as its ports saw it.", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "ports": { + "type": "integer" + } + } + }, + "fabrics.Member": { + "type": "object", + "description": "Member is one node's adapters on a physical fabric.", + "properties": { + "node": { + "type": "string" + }, + "hcas": { + "type": "array", + "items": { + "type": "string" + } + }, + "declared": { + "type": "string", + "description": "Declared is the name this node gives these adapters, empty when it declares them in no fabric." + } + } + }, + "fabrics.Physical": { + "type": "object", + "description": "Physical is one fabric as the adapters find it.", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "Kind is infiniband or roce." + }, + "label": { + "type": "string", + "description": "Label is the key as a person reads it." + }, + "sm": { + "$ref": "#/components/schemas/fabrics.SM", + "description": "SM is the master subnet manager, on InfiniBand: the one most of its ports see." + }, + "sms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.SM" + }, + "description": "SMs is every subnet manager the fabric lists, master first; empty when no port could ask. Standbys counts those that could take over." + }, + "standbys": { + "type": "integer" + }, + "masters": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Masters is how many ports see each master, when they do not all agree." + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Change" + }, + "description": "Changes are the master changes seen in the last day, newest first." + }, + "declared": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Declared are the names nodes gave this fabric; one, when all is well." + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Member" + } + }, + "rate_gbps": { + "type": "integer" + }, + "gid_prefix": { + "type": "string" + } + } + }, + "fabrics.Problem": { + "type": "object", + "description": "Problem is one disagreement, in words.", + "properties": { + "level": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "fabrics.Report": { + "type": "object", + "description": "Report is the whole picture.", + "properties": { + "physical": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Physical" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/fabrics.Problem" + } + }, + "unverified": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Unverified are declared fabrics some of whose nodes cannot say which physical fabric they are on, with why." + } + } + }, + "fabrics.SM": { + "type": "object", + "description": "SM is a subnet manager as the ports see it.", + "properties": { + "guid": { + "type": "string" + }, + "lid": { + "type": "integer" + }, + "priority": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "guard.Attachment": { + "type": "object", + "description": "Attachment ties a policy to a part of the tenancy tree: scope is org, team, project, user or key, and ScopeID which one.", + "properties": { + "Scope": { + "type": "string" + }, + "ScopeID": { + "type": "string" + }, + "Policy": { + "type": "string" + } + } + }, + "inventory.EngineInstall": { + "type": "object", + "description": "EngineInstall is where one engine's installation stands on a node.", + "properties": { + "engine": { + "type": "string" + }, + "version": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "state": { + "type": "string", + "description": "State is \"installing\", \"ready\", \"failed\" or \"unsupported\"." + }, + "detail": { + "type": "string" + } + } + }, + "inventory.GPUSensor": { + "type": "object", + "description": "GPUSensor is one card's heat and power, and what is throttling it.", + "properties": { + "index": { + "type": "integer" + }, + "temp_c": { + "type": "number" + }, + "memory_temp_c": { + "type": "number" + }, + "power_w": { + "type": "number" + }, + "power_limit_w": { + "type": "number" + }, + "throttle": { + "type": "array", + "items": { + "type": "string" + } + }, + "util_percent": { + "type": "number", + "description": "How busy the card is, as of this sample; -1 is not reported. Zero MemoryTotalMB is an agent too old to send these, whose figures are the ones it read at registration." + }, + "mem_util_percent": { + "type": "number" + }, + "memory_used_mb": { + "type": "number" + }, + "memory_total_mb": { + "type": "number" + }, + "sm_clock_mhz": { + "type": "number" + }, + "ecc_uncorrected": { + "type": "number" + }, + "wear": { + "$ref": "#/components/schemas/inventory.GPUWear", + "description": "Wear is what wears the card out, read every minute; nil from an agent that does not read it." + } + } + }, + "inventory.GPUWear": { + "type": "object", + "description": "GPUWear is a card's wear and failures. Counts of -1 are not reported for it: a card before Ampere has no remapped rows, one after no retired pages.", + "properties": { + "pcie_replays": { + "type": "integer", + "format": "int64" + }, + "retired_pages_sbe": { + "type": "integer", + "format": "int64" + }, + "retired_pages_dbe": { + "type": "integer", + "format": "int64" + }, + "retire_pending": { + "type": "boolean" + }, + "remapped_rows_correctable": { + "type": "integer", + "format": "int64" + }, + "remapped_rows_uncorrectable": { + "type": "integer", + "format": "int64" + }, + "remap_pending": { + "type": "boolean" + }, + "remap_failure": { + "type": "boolean" + }, + "nvlink_crc_errors": { + "type": "integer", + "format": "int64" + }, + "nvlink_replay_errors": { + "type": "integer", + "format": "int64" + }, + "nvlink_recovery_errors": { + "type": "integer", + "format": "int64" + }, + "xid_errors": { + "type": "integer", + "format": "int64" + }, + "last_xid": { + "type": "integer" + }, + "last_xid_text": { + "type": "string" + }, + "last_xid_at": { + "type": "string", + "format": "date-time" + } + } + }, + "modelplan.Option": { + "type": "object", + "description": "Option is one way to run one artifact.", + "properties": { + "artifact": { + "type": "string" + }, + "format": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "cpu": { + "type": "boolean" + }, + "gpu_class": { + "type": "string" + }, + "fabric": { + "type": "string" + }, + "gpus_per_node": { + "type": "integer", + "description": "GPUsPerNode cards on each of Nodes nodes." + }, + "nodes": { + "type": "integer" + }, + "need_gb": { + "type": "number", + "description": "NeedGB is what one card holds, or for CPU the whole replica in RAM; HaveGB what a card, or the node, offers for it." + }, + "have_gb": { + "type": "number" + }, + "hosts": { + "type": "integer", + "description": "Hosts is how many nodes could run it at all, and FreeNow whether one (or enough, together) could right now." + }, + "free_now": { + "type": "boolean" + }, + "bits_per_weight": { + "type": "number", + "description": "BitsPerWeight is the artifact's precision, 0 when unknown." + } + } + }, + "modelserve.CanaryView": { + "type": "object", + "description": "CanaryView is one model's canary as it stands.", + "properties": { + "model": { + "type": "string" + }, + "canary": { + "$ref": "#/components/schemas/catalog.Canary" + }, + "serving": { + "$ref": "#/components/schemas/canary.Stat" + } + } + }, + "modelserve.Job": { + "type": "object", + "description": "Job is one model being served, and where it stands.", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "Kind is \"update\" for a model moved to new weights, empty for one served." + }, + "request": { + "$ref": "#/components/schemas/modelserve.Request" + }, + "state": { + "type": "string" + }, + "error": { + "type": "string" + }, + "model": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.Step" + } + }, + "started": { + "type": "string", + "format": "date-time" + }, + "reply": { + "type": "string", + "description": "Reply is what the model said to the smoke test, and TokensPerSecond how fast it said it." + }, + "tokens_per_second": { + "type": "number" + } + } + }, + "modelserve.Plan": { + "type": "object", + "description": "Plan is every way a repository's files could run here, best first.", + "properties": { + "repo": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "format": { + "type": "string" + }, + "architecture": { + "type": "string" + }, + "license": { + "type": "string" + }, + "params": { + "type": "integer", + "format": "int64" + }, + "context": { + "type": "integer" + }, + "gated": { + "type": "boolean" + }, + "servable": { + "type": "boolean", + "description": "Servable is false when the nodes' engine cannot run the model at all, and Reason says why." + }, + "reason": { + "type": "string" + }, + "note": { + "type": "string", + "description": "Note is anything the plan could not know, said once." + }, + "projectors": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelserve.PlannedArtifact" + } + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/modelplan.Option" + } + } + } + }, + "modelserve.PlannedArtifact": { + "type": "object", + "description": "PlannedArtifact is one file a repository offers, and the best way to run it.", + "properties": { + "name": { + "type": "string", + "description": "Name is the file, or the set's name; for original weights, \"original\"." + }, + "format": { + "type": "string", + "description": "\"gguf\" or \"safetensors\"" + }, + "bytes": { + "type": "integer", + "format": "int64" + }, + "shards": { + "type": "integer" + }, + "quant": { + "type": "string", + "description": "Quant is the quantisation read from the name, like Q4_K_M; \"bf16\" for original weights." + }, + "best": { + "$ref": "#/components/schemas/modelplan.Option" + } + } + }, + "modelserve.Request": { + "type": "object", + "description": "Request is what to serve.", + "properties": { + "repo": { + "type": "string", + "description": "Repo is the registry repository, owner/name." + }, + "quant": { + "type": "string", + "description": "Quant picks the file by its quantisation, Q4_K_M unless given." + }, + "file": { + "type": "string", + "description": "File picks it by name instead." + }, + "projector": { + "type": "string", + "description": "Projector is a vision model's mmproj file in the repository, fetched beside the weights and loaded with them; empty serves text alone." + }, + "format": { + "type": "string", + "description": "Format is \"gguf\", run by llama.cpp, or \"safetensors\": the original weights with their config and tokenizer, run by vLLM on GPUs of GPUClass, across TP of them." + }, + "cpu": { + "type": "boolean", + "description": "Where it runs: on the CPU, or on GPUsPerNode cards of GPUClass on each of Nodes nodes. Left empty, the plan decides: the best way the cluster can run the chosen file. TP is GPUsPerNode's older name." + }, + "gpu_class": { + "type": "string" + }, + "gpus_per_node": { + "type": "integer" + }, + "nodes": { + "type": "integer" + }, + "tp": { + "type": "integer" + }, + "fallbacks": { + "type": "boolean", + "description": "Fallbacks adds the next best ways, lower in priority, so a model whose first choice is busy runs on another rather than waiting." + }, + "name": { + "type": "string", + "description": "Name is the catalog name; the repository suggests one otherwise." + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "replicas": { + "type": "integer", + "description": "Replicas to run; 0 makes it on demand, loaded when first asked for." + }, + "guard_format": { + "type": "string", + "description": "GuardFormat makes it a judge model for the guardrails (role: guard): llamaguard3, shieldgemma or json. Empty serves clients." + }, + "ctx_pool": { + "type": "integer", + "description": "CtxPool is llama.cpp's --ctx-size; ContextLength what one request may use. They default to 16384 and 4096, what four parallel slots divide." + }, + "context_length": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer", + "description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on." + } + } + }, + "modelserve.Step": { + "type": "object", + "description": "Step is one stage of a job.", + "properties": { + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "modelserve.UpdateInfo": { + "type": "object", + "description": "UpdateInfo is what the registry has for a model against what it runs.", + "properties": { + "model": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "current": { + "type": "string", + "description": "Current is the commit the model runs, Latest the repository's now." + }, + "latest": { + "type": "string" + }, + "changed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Changed are the model's files whose weights differ at Latest, and Missing those Latest no longer has at all." + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "bytes": { + "type": "integer", + "format": "int64", + "description": "Bytes is what an update downloads." + }, + "available": { + "type": "boolean", + "description": "Available is true when the model can be moved to Latest." + }, + "previous": { + "type": "string", + "description": "Previous is the commit an update replaced, which it can go back to." + }, + "checked_at": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": "string" + } + } + }, + "notify.Result": { + "type": "object", + "description": "Result is how one webhook answered a test.", + "properties": { + "url": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, + "nvlink.Domain": { + "type": "object", + "description": "Domain is one multi-node NVLink domain.", + "properties": { + "key": { + "type": "string" + }, + "cluster_uuid": { + "type": "string" + }, + "clique_id": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Member" + } + }, + "gpus": { + "type": "integer" + }, + "joined": { + "type": "integer" + }, + "usable_nodes": { + "type": "integer" + } + } + }, + "nvlink.GPU": { + "type": "object", + "description": "GPU is one card as its domain sees it.", + "properties": { + "index": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "bandwidth": { + "type": "string" + }, + "joined": { + "type": "boolean" + } + } + }, + "nvlink.Member": { + "type": "object", + "description": "Member is one node of a domain.", + "properties": { + "node": { + "type": "string" + }, + "gpus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.GPU" + } + }, + "usable": { + "type": "boolean", + "description": "Usable is every card joined and IMEX up: what placement requires." + }, + "imex": { + "type": "string" + }, + "imex_channels": { + "type": "integer" + } + } + }, + "nvlink.Problem": { + "type": "object", + "description": "Problem is one thing wrong with a domain.", + "properties": { + "level": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "nvlink.Report": { + "type": "object", + "description": "Report is every domain and what is wrong with them.", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Domain" + } + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/components/schemas/nvlink.Problem" + } + } + } + }, + "promq.Point": { + "type": "object", + "description": "Point is one sample.", + "properties": { + "At": { + "type": "string", + "format": "date-time" + }, + "Value": { + "type": "number" + } + } + }, + "promq.Series": { + "type": "object", + "description": "Series is one line: a name and its points, oldest first.", + "properties": { + "Name": { + "type": "string" + }, + "Points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/promq.Point" + } + } + } + }, + "quota.Amount": { + "type": "object", + "description": "Amount is how much of each resource something holds or needs.", + "properties": { + "GPUs": { + "type": "integer" + }, + "CPUCores": { + "type": "integer" + }, + "MemoryGB": { + "type": "integer" + } + } + }, + "store.AuditEvent": { + "type": "object", + "description": "AuditEvent is one change: who made it, through what, to what, and how it ended. Detail never holds a secret; the writer takes them out.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "actor": { + "type": "string" + }, + "source": { + "type": "string", + "description": "api, web" + }, + "action": { + "type": "string", + "description": "\"POST /api/v1/models\", \"POST /admin/users/{id}/suspend\"" + }, + "target": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "store.ExportRow": { + "type": "object", + "description": "ExportRow is one line of a usage export: a period, the place, person, key and model it is about — those asked for, the rest empty — and what was used.", + "properties": { + "Period": { + "type": "string", + "format": "date-time" + }, + "Org": { + "type": "string" + }, + "Team": { + "type": "string" + }, + "Project": { + "type": "string" + }, + "User": { + "type": "string" + }, + "Key": { + "type": "string" + }, + "Model": { + "type": "string" + }, + "Requests": { + "type": "integer", + "format": "int64" + }, + "Errors": { + "type": "integer", + "format": "int64" + }, + "Limited": { + "type": "integer", + "format": "int64" + }, + "Prompt": { + "type": "integer", + "format": "int64" + }, + "Completion": { + "type": "integer", + "format": "int64" + }, + "CostMicros": { + "type": "integer", + "format": "int64" + } + } + }, + "store.RequestBody": { + "type": "object", + "description": "RequestBody is one kept request and its answer.", + "properties": { + "request_id": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "model": { + "type": "string" + }, + "served_model": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "request": { + "type": "string" + }, + "response": { + "type": "string", + "description": "Response is the answer's text as the client was sent it; Truncated says it was longer than what is kept." + }, + "truncated": { + "type": "boolean" + } + } + }, + "store.Resources": { + "type": "object", + "description": "Resources are amounts of the three things a replica holds of a cluster. Nil is not governed: nothing guaranteed, or nothing limited.", + "properties": { + "gpus": { + "type": "integer" + }, + "cpu_cores": { + "type": "integer" + }, + "memory_gb": { + "type": "integer" + } + } + }, + "topology.HostView": { + "type": "object", + "description": "HostView is an adapter, and the node it is in when the cluster knows it.", + "properties": { + "guid": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "node": { + "type": "string" + }, + "hca": { + "type": "string" + }, + "rail": { + "type": "integer" + }, + "leaf": { + "type": "string" + } + } + }, + "topology.LinkView": { + "type": "object", + "description": "LinkView is a cable, with what it trained at and whether that is short of its kind.", + "properties": { + "from": { + "type": "string" + }, + "from_port": { + "type": "integer" + }, + "to": { + "type": "string" + }, + "to_port": { + "type": "integer" + }, + "rate": { + "type": "string" + }, + "gbps": { + "type": "integer" + }, + "host": { + "type": "boolean" + }, + "degraded": { + "type": "boolean" + } + } + }, + "topology.Rail": { + "type": "object", + "description": "Rail is the leaves one rail's adapters reach.", + "properties": { + "index": { + "type": "integer" + }, + "leaves": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "topology.SwitchView": { + "type": "object", + "description": "SwitchView is a switch and its place in the tiers.", + "properties": { + "guid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tier": { + "type": "integer", + "description": "1 leaf, 2 spine, 3 core" + }, + "ports": { + "type": "integer" + }, + "used": { + "type": "integer" + }, + "lid": { + "type": "integer" + }, + "hosts": { + "type": "integer", + "description": "Hosts counts adapters hanging off it." + } + } + }, + "topology.View": { + "type": "object", + "description": "View is a fabric's shape, ready to draw.", + "properties": { + "switches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.SwitchView" + } + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.HostView" + } + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.LinkView" + } + }, + "tiers": { + "type": "integer", + "description": "Tiers is how many switch levels: 1 for a single switch, 2 for leaf and spine, 3 with a core." + }, + "expected_host_gbps": { + "type": "integer", + "description": "Expected is the rate most links of each kind trained at; a link below it is degraded." + }, + "expected_switch_gbps": { + "type": "integer" + }, + "degraded": { + "type": "integer" + }, + "rails": { + "type": "array", + "items": { + "$ref": "#/components/schemas/topology.Rail" + }, + "description": "Rails is, per rail — an adapter's position on its node — the leaves it reaches, and Aligned whether each rail has its own." + }, + "rail_aligned": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } +} diff --git a/docs/install.md b/docs/install.md index c5923ce..8dca45a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -252,7 +252,8 @@ knowing before you edit it: `LLMGW_ADMIN_TOKEN` gives it from elsewhere. pprof, whose heap holds the API keys requests presented, is off unless `debug_pprof: true`, and then behind the token too. To see the dashboard from elsewhere use `/admin` on the - client port — do not move this listener. + client port — do not move this listener. The API itself is described in + [admin-api.md](admin-api.md). - `default_rpm`, `default_tpm` and `default_price` are what make limits and budgets mean anything. Without a price every request costs zero and a budget never binds; without the defaults, a project created without limits is diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 920b88b..ca59ada 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -75,13 +75,26 @@ func (a *API) SetRevoker(f func(ctx context.Context, nodeID string, at time.Time a.revoke = f } +// Mux is where the endpoints are mounted. An *http.ServeMux is one; a test +// that records the patterns is another, and is how the route table is checked +// against what is actually served. +type Mux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) +} + // Register mounts the endpoints on a mux under /api/v1. -func (a *API) Register(mux *http.ServeMux) { +// +// Every pattern registered here, or by the register helpers it calls, must +// also be in Routes: that table is what the OpenAPI document is generated +// from, and a test fails when the two disagree. +func (a *API) Register(mux Mux) { mux.HandleFunc("GET /api/v1/summary", a.summary) - // Adding a model is a write, and the only one this API has. It lives here - // rather than on the client-facing listener for the same reason the rest of - // it does: reaching this port already means holding the control plane. + mux.HandleFunc("GET /api/v1/openapi.json", serveOpenAPI) + // Changes to the catalog live here rather than on the client-facing + // listener for the same reason the rest of this does: reaching this port + // already means holding the control plane. mux.HandleFunc("POST /api/v1/models", a.addModel) + mux.HandleFunc("GET /api/v1/models/{name}", a.getModel) mux.HandleFunc("DELETE /api/v1/models/{name}", a.removeModel) mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel) mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources) @@ -97,9 +110,7 @@ func (a *API) Register(mux *http.ServeMux) { writeError(w, http.StatusNotImplemented, "this control plane has no store") return } - var req struct { - Class string `json:"class"` - } + var req ClassRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -109,7 +120,7 @@ func (a *API) Register(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "class": m.EffectiveClass()}) + writeJSON(w, http.StatusOK, ClassView{Model: m.Name, Class: m.EffectiveClass()}) }) mux.HandleFunc("PUT /api/v1/models/{name}/capacity", a.putCapacity) mux.HandleFunc("GET /api/v1/capacity", a.listCapacity) @@ -953,7 +964,25 @@ func (a *API) addModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"added": m.Name}) + writeJSON(w, http.StatusOK, ModelAdded{Added: m.Name}) +} + +// getModel answers with one model's whole definition as it is in force: what +// was added, with the defaults the catalog filled in. The listing shows a +// model's state and only the names of its profiles; this is what a tool +// managing the definition — Terraform, say — compares against what it wants. +// +// By its name only, not an alias: an alias answering would let a tool believe +// a model it manages exists when it is another one. +func (a *API) getModel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + for _, m := range a.ctl.Catalog().Models() { + if m.Name == name { + writeJSON(w, http.StatusOK, m) + return + } + } + writeError(w, http.StatusNotFound, "no model named "+name+" is in the catalog") } // ScaleRequest is how many replicas a model should run. @@ -1039,7 +1068,7 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"removed": name}) + writeJSON(w, http.StatusOK, Removed{Removed: name}) } func (a *API) summary(w http.ResponseWriter, _ *http.Request) { @@ -1137,15 +1166,13 @@ func (a *API) removeNode(w http.ResponseWriter, r *http.Request) { } inv.Remove(id) a.ctl.Reconciler().Notify() - writeJSON(w, http.StatusOK, map[string]string{"id": id, "removed": "true"}) + writeJSON(w, http.StatusOK, NodeRemoved{ID: id, Removed: "true"}) } func (a *API) setNodeState(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - var body struct { - State string `json:"state"` - } + var body NodeStateRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1173,7 +1200,7 @@ func (a *API) setNodeState(w http.ResponseWriter, r *http.Request) { // is usually about to do something to it. a.ctl.Reconciler().Notify() - writeJSON(w, http.StatusOK, map[string]string{"id": id, "state": st.String()}) + writeJSON(w, http.StatusOK, NodeStateView{ID: id, State: st.String()}) } func writeJSON(w http.ResponseWriter, code int, v any) { @@ -1183,7 +1210,7 @@ func writeJSON(w http.ResponseWriter, code int, v any) { } func writeError(w http.ResponseWriter, code int, msg string) { - writeJSON(w, code, map[string]string{"error": msg}) + writeJSON(w, code, ErrorView{Error: msg}) } // profileChanged reports whether a replica is running a profile the catalog no @@ -1384,7 +1411,7 @@ func (a *API) retryModel(w http.ResponseWriter, r *http.Request) { return } was := a.ctl.Reconciler().Retry(name) - writeJSON(w, http.StatusOK, map[string]bool{"was_parked": was}) + writeJSON(w, http.StatusOK, RetryView{WasParked: was}) } // requestFinder looks a request up by the id it was given. Asked of the usage @@ -1477,7 +1504,7 @@ func (a *API) upgradeNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadGateway, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"node": r.PathValue("id"), "version": req.Version}) + writeJSON(w, http.StatusOK, UpgradeView{Node: r.PathValue("id"), Version: req.Version}) } // SetServe supplies what serves a model from the registry. @@ -1577,7 +1604,7 @@ func (a *API) rollbackModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) } // OriginRequest names the repository a model's weights came from. diff --git a/internal/control/adminapi/canary.go b/internal/control/adminapi/canary.go index bba0821..2b4f856 100644 --- a/internal/control/adminapi/canary.go +++ b/internal/control/adminapi/canary.go @@ -8,7 +8,13 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" ) -func (a *API) registerCanary(mux *http.ServeMux) { +// CanaryPercentRequest changes the share of a model's requests its canary +// takes. +type CanaryPercentRequest struct { + Percent int `json:"percent"` +} + +func (a *API) registerCanary(mux Mux) { need := func(w http.ResponseWriter) bool { if a.serve == nil { writeError(w, http.StatusNotImplemented, "a canary needs a mirror and a store, and this control plane lacks one") @@ -45,9 +51,7 @@ func (a *API) registerCanary(mux *http.ServeMux) { if !need(w) { return } - var req struct { - Percent int `json:"percent"` - } + var req CanaryPercentRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -57,7 +61,7 @@ func (a *API) registerCanary(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) }) mux.HandleFunc("DELETE /api/v1/models/{name}/canary", func(w http.ResponseWriter, r *http.Request) { if !need(w) { @@ -68,7 +72,7 @@ func (a *API) registerCanary(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"result": msg}) + writeJSON(w, http.StatusOK, Result{Result: msg}) }) mux.HandleFunc("POST /api/v1/models/{name}/promote", func(w http.ResponseWriter, r *http.Request) { if !need(w) { diff --git a/internal/control/adminapi/fabrictest.go b/internal/control/adminapi/fabrictest.go index fbe4f4d..5c028ae 100644 --- a/internal/control/adminapi/fabrictest.go +++ b/internal/control/adminapi/fabrictest.go @@ -34,10 +34,7 @@ type RailView struct { // sends traffic over the fabric for seconds a rail, so it is a POST and only // ever run when asked. func (a *API) fabricTest(w http.ResponseWriter, r *http.Request) { - var body struct { - A, B, Fabric string - Seconds int - } + var body FabricTestRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/control/adminapi/guard.go b/internal/control/adminapi/guard.go index 33d76da..78a744f 100644 --- a/internal/control/adminapi/guard.go +++ b/internal/control/adminapi/guard.go @@ -66,7 +66,17 @@ func (a *API) guardTarget(ctx context.Context, target string) (guard.Attachment, return guard.Attachment{}, fmt.Errorf("%q: attach to org, team, project, key or user", kind) } -func (a *API) registerGuard(mux *http.ServeMux) { +// GuardAttachmentRequest attaches a policy to a part of the tenancy tree, a +// key or a person, or with DELETE takes it off again. +type GuardAttachmentRequest struct { + // Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID + // after the colon), key:KEY-ID or user:USER-ID. + Target string `json:"target"` + // Policy is a policy's name in server.yaml's guard.policies. + Policy string `json:"policy"` +} + +func (a *API) registerGuard(mux Mux) { mux.HandleFunc("GET /api/v1/guard", func(w http.ResponseWriter, r *http.Request) { if a.guard == nil { writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml") @@ -97,12 +107,7 @@ func (a *API) registerGuard(mux *http.ServeMux) { writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml") return } - var at struct { - // Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT - // (or the ID after the colon), key:KEY-ID or user:USER-ID. - Target string `json:"target"` - Policy string `json:"policy"` - } + var at GuardAttachmentRequest if err := json.NewDecoder(r.Body).Decode(&at); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/internal/control/adminapi/quotas.go b/internal/control/adminapi/quotas.go index 3810bcd..05b9361 100644 --- a/internal/control/adminapi/quotas.go +++ b/internal/control/adminapi/quotas.go @@ -50,7 +50,26 @@ type QuotaRequest struct { Limit store.Resources `json:"limit"` } -func (a *API) registerQuotas(mux *http.ServeMux) { +// QuotaSet is the scope a quota was set on, as org:ID, team:ID or +// project:ID whatever name it was asked by. +type QuotaSet struct { + Scope string `json:"scope"` +} + +// OwnerRequest sets the part of the tenancy tree a model's capacity counts +// against: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or their IDs. +// Empty makes the model shared. +type OwnerRequest struct { + Owner string `json:"owner"` +} + +// OwnerView is a model's owner now, as scope:ID. +type OwnerView struct { + Model string `json:"model"` + Owner string `json:"owner"` +} + +func (a *API) registerQuotas(mux Mux) { mux.HandleFunc("GET /api/v1/quotas", func(w http.ResponseWriter, r *http.Request) { if a.quotas == nil { writeError(w, http.StatusNotImplemented, "this control plane has no store") @@ -78,7 +97,7 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"scope": key}) + writeJSON(w, http.StatusOK, QuotaSet{Scope: key}) }) mux.HandleFunc("DELETE /api/v1/quotas/{scope}", func(w http.ResponseWriter, r *http.Request) { if a.quotas == nil { @@ -90,16 +109,14 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"removed": key}) + writeJSON(w, http.StatusOK, Removed{Removed: key}) }) mux.HandleFunc("POST /api/v1/models/{name}/owner", func(w http.ResponseWriter, r *http.Request) { if a.models == nil || a.quotas == nil { writeError(w, http.StatusNotImplemented, "this control plane has no store") return } - var req struct { - Owner string `json:"owner"` - } + var req OwnerRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error()) return @@ -118,7 +135,7 @@ func (a *API) registerQuotas(mux *http.ServeMux) { writeError(w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "owner": m.Owner}) + writeJSON(w, http.StatusOK, OwnerView{Model: m.Name, Owner: m.Owner}) }) } diff --git a/internal/control/adminapi/replies.go b/internal/control/adminapi/replies.go new file mode 100644 index 0000000..d221c9e --- /dev/null +++ b/internal/control/adminapi/replies.go @@ -0,0 +1,84 @@ +package adminapi + +// The small bodies the endpoints read and answer with. Named types rather +// than maps and anonymous structs, so the OpenAPI document generated from +// Routes describes exactly what a handler decodes and writes: a map literal +// in a handler is a shape no generator can see. + +// ErrorView is every refusal: a 4xx or 5xx status with what went wrong. +type ErrorView struct { + Error string `json:"error"` +} + +// Result is what a change that has no other answer says it did. +type Result struct { + Result string `json:"result"` +} + +// ModelAdded names the model added to the catalog. +type ModelAdded struct { + Added string `json:"added"` +} + +// Removed names what was taken away: a model, or a quota's scope. +type Removed struct { + Removed string `json:"removed"` +} + +// ClassRequest sets a model's capacity class: critical, standard or +// opportunistic, or empty to go back to what its scaling implies. +type ClassRequest struct { + Class string `json:"class"` +} + +// ClassView is a model's capacity class now. +type ClassView struct { + Model string `json:"model"` + Class string `json:"class"` +} + +// RetryView says whether the model had been parked, given up on after +// failing to start, before it was retried. +type RetryView struct { + WasParked bool `json:"was_parked"` +} + +// NodeStateRequest cordons, drains or readies a node: ready (or uncordon), +// cordoned (or cordon), draining (or drain). +type NodeStateRequest struct { + State string `json:"state"` +} + +// NodeStateView is a node's state after the change. +type NodeStateView struct { + ID string `json:"id"` + State string `json:"state"` +} + +// NodeRemoved names the node forgotten. Removed is the string "true". +type NodeRemoved struct { + ID string `json:"id"` + Removed string `json:"removed"` +} + +// UpgradeView is the release a node was told to run. +type UpgradeView struct { + Node string `json:"node"` + Version string `json:"version"` +} + +// FabricTestRequest is a bandwidth test between two nodes. +type FabricTestRequest struct { + A string `json:"a"` + B string `json:"b"` + // Fabric is the declared fabric to test over; empty is the one the two + // nodes share. + Fabric string `json:"fabric,omitempty"` + // Seconds each rail is measured for, from 1 to 30. + Seconds int `json:"seconds,omitempty"` +} + +// ReplayRequest names the model a kept request is sent to again. +type ReplayRequest struct { + Model string `json:"model"` +} diff --git a/internal/control/adminapi/requestbody.go b/internal/control/adminapi/requestbody.go index edfbe2c..27e89bc 100644 --- a/internal/control/adminapi/requestbody.go +++ b/internal/control/adminapi/requestbody.go @@ -60,9 +60,7 @@ func (a *API) replayRequest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotImplemented, "this control plane does not route requests") return } - var req struct { - Model string `json:"model"` - } + var req ReplayRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil || req.Model == "" { writeError(w, http.StatusBadRequest, "say which model: {\"model\": \"...\"}") return diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go new file mode 100644 index 0000000..273bc01 --- /dev/null +++ b/internal/control/adminapi/routes.go @@ -0,0 +1,249 @@ +package adminapi + +import ( + "net/http" + + llmgw "gitea.mynodes.xyz/ilker/llmgw" + "gitea.mynodes.xyz/ilker/llmgw/internal/bench" + "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.", + "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", + 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", + Response: modelserve.Job{}, Status: http.StatusAccepted}, + + // 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) +} diff --git a/internal/control/adminapi/routes_test.go b/internal/control/adminapi/routes_test.go new file mode 100644 index 0000000..4831e58 --- /dev/null +++ b/internal/control/adminapi/routes_test.go @@ -0,0 +1,119 @@ +package adminapi + +import ( + "net/http" + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" +) + +// patterns records what Register mounts. +type patterns []string + +func (p *patterns) HandleFunc(pattern string, _ func(http.ResponseWriter, *http.Request)) { + *p = append(*p, pattern) +} + +func TestEveryRegisteredEndpointIsInTheRouteTable(t *testing.T) { + // The OpenAPI document is generated from Routes, so an endpoint missing + // from it is one no client generated from the document can call, and one + // in it that is not served is a promise the server breaks. + a, _ := setup(t, testModels()) + var got patterns + a.Register(&got) + + table := map[string]bool{} + for _, r := range Routes { + key := r.Method + " " + r.Path + if table[key] { + t.Errorf("%s is in Routes twice", key) + } + table[key] = true + } + served := map[string]bool{} + for _, p := range got { + served[p] = true + if !table[p] { + t.Errorf("%s is registered and not in Routes: describe it there", p) + } + } + for key := range table { + if !served[key] { + t.Errorf("%s is in Routes and not registered", key) + } + } +} + +func TestEveryRouteSaysWhatItAnswers(t *testing.T) { + for _, r := range Routes { + if r.Summary == "" || r.Response == nil { + t.Errorf("%s %s needs a summary and a response type", r.Method, r.Path) + } + if r.Request != nil && (r.Method == "GET" || r.Method == "HEAD") { + t.Errorf("%s %s reads a body on a GET", r.Method, r.Path) + } + for _, seg := range strings.Split(strings.Trim(r.Path, "/"), "/") { + if !strings.HasPrefix(seg, "{") { + continue + } + found := false + for k := range PathParams { + if strings.HasSuffix(k, "/"+seg) && strings.Contains(r.Path, k) { + found = true + } + } + if !found { + t.Errorf("%s %s: %s is not described in PathParams", r.Method, r.Path, seg) + } + } + } +} + +func TestTheAPIServesItsOwnDescription(t *testing.T) { + a, _ := setup(t, testModels()) + var doc struct { + OpenAPI string `json:"openapi"` + Paths map[string]map[string]any `json:"paths"` + } + if code := call(t, a, "GET", "/api/v1/openapi.json", "", &doc); code != 200 { + t.Fatalf("status %d", code) + } + if doc.OpenAPI != "3.1.0" { + t.Errorf("openapi %q", doc.OpenAPI) + } + // What is built in is the document generated when it was last committed; + // internal/docgen's test is what fails when that is stale. This checks the + // two ends meet: every route the table has is in what is served. + for _, r := range Routes { + if _, ok := doc.Paths[r.Path][strings.ToLower(r.Method)]; !ok { + t.Errorf("the served document has no %s %s", r.Method, r.Path) + } + } +} + +func TestOneModelsDefinitionIsReadByItsNameAlone(t *testing.T) { + a, _ := setup(t, testModels()) + var m catalog.Model + if code := call(t, a, "GET", "/api/v1/models/llama", "", &m); code != 200 { + t.Fatalf("status %d", code) + } + if m.Name != "llama" || m.Source != "/m" || len(m.Profiles) != 1 || m.Profiles[0].TP != 8 { + t.Errorf("got %+v", m) + } + if m.Scaling.Min != 2 || m.Scaling.Max != 4 { + t.Errorf("scaling %+v", m.Scaling) + } + if !slices.Equal(m.Aliases, []string{"l"}) { + t.Errorf("aliases %v", m.Aliases) + } + // An alias answering would let a tool that manages "l" believe it exists + // when what it found is llama. + if code := call(t, a, "GET", "/api/v1/models/l", "", nil); code != http.StatusNotFound { + t.Errorf("an alias answered %d", code) + } + if code := call(t, a, "GET", "/api/v1/models/nothing", "", nil); code != http.StatusNotFound { + t.Errorf("a missing model answered %d", code) + } +} diff --git a/internal/control/adminapi/weights.go b/internal/control/adminapi/weights.go index ac8e904..d7c4592 100644 --- a/internal/control/adminapi/weights.go +++ b/internal/control/adminapi/weights.go @@ -65,6 +65,20 @@ func (a *API) listOrphans(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, a.Orphans()) } +// CollectRequest limits a sweep to one node. Empty means every node, which +// is the common case after removing a model from the catalog. +type CollectRequest struct { + Node string `json:"node,omitempty"` +} + +// CollectResult is what was deleted on one node, or why nothing was. +type CollectResult struct { + Node string `json:"node"` + Files int `json:"files"` + Bytes int64 `json:"bytes"` + Error string `json:"error,omitempty"` +} + // collectWeights deletes them. // // A proposal, not an instruction: each node checks every path again before @@ -79,26 +93,16 @@ func (a *API) collectWeights(w http.ResponseWriter, r *http.Request) { return } - var body struct { - // Node limits it to one node. Empty means every node, which is the - // common case after removing a model from the catalog. - Node string `json:"node,omitempty"` - } + var body CollectRequest // An empty body is the "all nodes" case and must not be an error. _ = json.NewDecoder(r.Body).Decode(&body) - type result struct { - Node string `json:"node"` - Files int `json:"files"` - Bytes int64 `json:"bytes"` - Error string `json:"error,omitempty"` - } - out := make([]result, 0) + out := make([]CollectResult, 0) for _, o := range a.Orphans() { if body.Node != "" && o.Node != body.Node { continue } - res := result{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes} + res := CollectResult{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes} // A node that has gone since the list was built is reported rather than // failing the whole sweep: the other nodes' space is still worth // freeing, and this one will be offered again next time. diff --git a/internal/docgen/gen/main.go b/internal/docgen/gen/main.go index bdf320f..a6c5f85 100644 --- a/internal/docgen/gen/main.go +++ b/internal/docgen/gen/main.go @@ -1,4 +1,5 @@ -// Command gen writes docs/reference.md from the configuration structs. +// Command gen writes docs/reference.md from the configuration structs, and +// docs/admin-api.openapi.json from the admin API route table. // // Run by `go generate ./...`, and checked by a test, so the reference cannot // quietly stop describing the code. @@ -10,7 +11,9 @@ import ( "path/filepath" "runtime" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/docgen" + "gitea.mynodes.xyz/ilker/llmgw/internal/docgen/openapi" ) func main() { @@ -43,4 +46,15 @@ func main() { os.Exit(1) } fmt.Printf("docs/reference.md and internal/docgen/reference_gen.go: %d blocks\n", len(blocks)) + + spec, err := openapi.Generate(root, adminapi.Routes, adminapi.PathParams) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + if err := os.WriteFile(filepath.Join(root, "docs", "admin-api.openapi.json"), spec, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + fmt.Printf("docs/admin-api.openapi.json: %d routes\n", len(adminapi.Routes)) } diff --git a/internal/docgen/openapi/openapi.go b/internal/docgen/openapi/openapi.go new file mode 100644 index 0000000..8c616a1 --- /dev/null +++ b/internal/docgen/openapi/openapi.go @@ -0,0 +1,672 @@ +// Package openapi generates the admin API's OpenAPI 3.1 document from its +// route table, adminapi.Routes. +// +// The schemas are read from the Go types the handlers decode and write, by +// reflection, following encoding/json's rules — tag names, omitted fields, +// embedded structs flattened — so the document says what goes over the wire +// rather than what someone remembered to write down. Their descriptions are +// the types' and fields' doc comments, read from the source, which is why +// generating needs the repository and is done by `go generate` rather than +// at run time: the binary serves the document built into it. +package openapi + +import ( + "bytes" + "encoding" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "reflect" + "slices" + "sort" + "strings" + "time" + "unicode" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +// Module is the import path the repository's packages are under; a type +// from any other is described without comments. +const Module = "gitea.mynodes.xyz/ilker/llmgw" + +// Generate renders the document for routes, reading doc comments from the +// repository at root. +func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) { + g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}} + + // Every named struct reachable from a body first, so a name two + // packages share can be told apart before either is written: by the name + // alone where it is unique, with its package where it is not. + for _, r := range routes { + for _, v := range []any{r.Request, r.Response} { + if v != nil { + g.collect(reflect.TypeOf(v)) + } + } + } + g.collect(errorType) + g.assignNames() + errorSchema := g.schema(errorType) + + paths := map[string]*object{} + var order []string + tags := map[string]bool{} + for _, r := range routes { + item, ok := paths[r.Path] + if !ok { + item = &object{} + paths[r.Path] = item + order = append(order, r.Path) + } + op, err := g.operation(r, pathParams) + if err != nil { + return nil, err + } + tags[tagOf(r.Path)] = true + item.set(strings.ToLower(r.Method), op) + } + slices.Sort(order) + var pathsObj object + for _, p := range order { + pathsObj.set(p, *paths[p]) + } + + var schemas object + names := make([]string, 0, len(g.schemas)) + for n := range g.schemas { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + schemas.set(n, g.schemas[n]) + } + + var tagList []any + for _, t := range sortedKeys(tags) { + tagList = append(tagList, obj("name", t)) + } + + doc := obj( + "openapi", "3.1.0", + "info", obj( + "title", "llmgw admin API", + "version", "v1", + "description", infoDescription, + ), + "servers", []any{obj("url", "http://127.0.0.1:9090", + "description", "The admin listener at its default address, admin_listen.")}, + "security", []any{obj("adminToken", []any{})}, + "tags", tagList, + "paths", pathsObj, + "components", obj( + "securitySchemes", obj("adminToken", obj( + "type", "http", + "scheme", "bearer", + "description", "The admin token: the contents of /var/lib/llmgw/admin-token on the "+ + "control plane (admin_token_file), readable by root and the server. "+ + "Sent as Authorization: Bearer TOKEN.", + )), + "parameters", obj("Actor", obj( + "name", "X-Llmgw-Actor", + "in", "header", + "required", false, + "description", "Who the change is recorded as made by in the audit log: letters, digits "+ + "and ._-@, at most 64. unknown when absent.", + "schema", obj("type", "string"), + )), + "responses", obj( + "Error", obj("description", "Refused: the error says why.", + "content", obj("application/json", obj("schema", errorSchema))), + "Unauthorized", obj("description", "The admin token is missing or wrong.", + "content", obj("application/json", obj("schema", errorSchema))), + ), + "schemas", schemas, + ), + ) + b, err := marshal(doc) + if err != nil { + return nil, err + } + var out bytes.Buffer + if err := json.Indent(&out, b, "", " "); err != nil { + return nil, err + } + out.WriteByte('\n') + return out.Bytes(), nil +} + +const infoDescription = "The control plane's management API: what `llmgw ctl` and the console use.\n\n" + + "It is served on the admin listener (admin_listen, 127.0.0.1:9090 by default), which stays on " + + "loopback unless admin_remote is set: reach it from elsewhere through an SSH tunnel. Every path " + + "under /api needs the admin token as a bearer token.\n\n" + + "The listener also refuses, before the token is looked at: a Host header that is a name rather " + + "than an address or localhost; a request another site's page sent; and any method but GET and " + + "HEAD whose Content-Type is not application/json, even one with no body.\n\n" + + "Durations in bodies are integer nanoseconds, as Go encodes them. Generated from the route " + + "table in internal/control/adminapi; see docs/admin-api.md." + +// operation builds one route's operation object. +func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) { + var op object + op.set("operationId", operationID(r.Method, r.Path)) + op.set("summary", r.Summary) + if r.Description != "" { + op.set("description", r.Description) + } + op.set("tags", []any{tagOf(r.Path)}) + + var params []any + segs := strings.Split(strings.Trim(r.Path, "/"), "/") + for i, s := range segs { + if !strings.HasPrefix(s, "{") { + continue + } + name := strings.Trim(s, "{}") + key := s + if i > 0 { + key = segs[i-1] + "/" + s + } + desc, ok := pathParams[key] + if !ok { + return nil, fmt.Errorf("%s %s: no description of %s in adminapi.PathParams", r.Method, r.Path, key) + } + params = append(params, obj("name", name, "in", "path", "required", true, + "description", desc, "schema", obj("type", "string"))) + } + for _, q := range r.Query { + t := q.Type + if t == "" { + t = "string" + } + p := obj("name", q.Name, "in", "query") + if q.Required { + p.set("required", true) + } + p.set("description", q.Description) + p.set("schema", obj("type", t)) + params = append(params, p) + } + if r.Method != "GET" && r.Method != "HEAD" { + params = append(params, obj("$ref", "#/components/parameters/Actor")) + } + if len(params) > 0 { + op.set("parameters", params) + } + + if r.Request != nil { + body := obj("required", !r.OptionalBody, + "content", obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Request))))) + op.set("requestBody", body) + } + + status := r.Status + if status == 0 { + status = 200 + } + content := obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response)))) + if r.CSV { + content.set("text/csv", obj("schema", obj("type", "string"))) + } + desc := "OK" + if status == 202 { + desc = "Accepted: started, and running on." + } + op.set("responses", obj( + fmt.Sprint(status), obj("description", desc, "content", content), + "401", obj("$ref", "#/components/responses/Unauthorized"), + "default", obj("$ref", "#/components/responses/Error"), + )) + return op, nil +} + +// operationID is the method and the path's words, getModelsNameTarget. +func operationID(method, p string) string { + var b strings.Builder + b.WriteString(strings.ToLower(method)) + for _, s := range strings.Split(strings.TrimPrefix(p, "/api/v1/"), "/") { + for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { + b.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + } + return b.String() +} + +// tagOf groups an operation by the first word of its path. +func tagOf(p string) string { + first, _, _ := strings.Cut(strings.TrimPrefix(p, "/api/v1/"), "/") + return strings.TrimSuffix(first, ".json") +} + +// --- schemas --- + +type typeDoc struct { + doc string + fields map[string]string +} + +type generator struct { + root string + docs map[string]map[string]typeDoc + names map[reflect.Type]string + schemas map[string]any + seen []reflect.Type +} + +var ( + timeType = reflect.TypeOf(time.Time{}) + durationType = reflect.TypeOf(time.Duration(0)) + rawType = reflect.TypeOf(json.RawMessage{}) + errorType = reflect.TypeOf(adminapi.ErrorView{}) + marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() +) + +// collect records every named struct reachable from t. +func (g *generator) collect(t reflect.Type) { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array || t.Kind() == reflect.Map { + if t == rawType { + return + } + t = t.Elem() + } + if t.Kind() != reflect.Struct || special(t) { + return + } + if t.Name() != "" { + if slices.Contains(g.seen, t) { + return + } + g.seen = append(g.seen, t) + } + for _, f := range fields(t) { + g.collect(f.typ) + } +} + +// assignNames names each struct's schema: by its name alone when it is the +// admin API's own, with its package's otherwise — catalog.Model, +// modelserve.Job. Qualified always rather than only on a collision, so a +// type added elsewhere one day cannot rename a schema clients already use. +func (g *generator) assignNames() { + own := reflect.TypeOf(adminapi.Route{}).PkgPath() + for _, t := range g.seen { + n := t.Name() + if t.PkgPath() != own { + n = path.Base(t.PkgPath()) + "." + n + } + g.names[t] = n + } + g.schemas = map[string]any{} +} + +func (g *generator) nameOf(t reflect.Type) string { return g.names[t] } + +// special is a type encoded other than by its fields. +func special(t reflect.Type) bool { + if t == timeType || t == durationType || t == rawType { + return true + } + return t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType) || + t.Implements(textType) || reflect.PointerTo(t).Implements(textType) +} + +func ref(name string) object { return obj("$ref", "#/components/schemas/"+name) } + +// schema describes t, adding the named structs it reaches to the components. +func (g *generator) schema(t reflect.Type) any { + switch { + case t == timeType: + return obj("type", "string", "format", "date-time") + case t == durationType: + return obj("type", "integer", "format", "int64", "description", "A duration, in nanoseconds.") + case t == rawType: + return obj("description", "Any JSON value.") + case t.Implements(textType) || reflect.PointerTo(t).Implements(textType): + return obj("type", "string") + case t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType): + return obj("description", "Encoded by the type's own JSON encoding.") + } + switch t.Kind() { + case reflect.Bool: + return obj("type", "boolean") + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint8, reflect.Uint16, reflect.Uint32: + return obj("type", "integer") + case reflect.Int64, reflect.Uint, reflect.Uint64: + return obj("type", "integer", "format", "int64") + case reflect.Float32, reflect.Float64: + return obj("type", "number") + case reflect.String: + return obj("type", "string") + case reflect.Pointer: + return g.schema(t.Elem()) + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + return obj("type", "string", "contentEncoding", "base64") + } + return obj("type", "array", "items", g.schema(t.Elem())) + case reflect.Map: + return obj("type", "object", "additionalProperties", g.schema(t.Elem())) + case reflect.Interface: + return obj() + case reflect.Struct: + if t.Name() == "" { + return g.structSchema(t, "") + } + name := g.names[t] + if name == "" { + // Reached only through a body collect did not walk, which would be + // a bug here; named anyway rather than inlined so it shows. + name = t.Name() + g.names[t] = name + } + if _, ok := g.schemas[name]; !ok { + g.schemas[name] = nil // placeholder: a type that contains itself + g.schemas[name] = g.structSchema(t, g.typeDocOf(t).doc) + } + return ref(name) + } + return obj() +} + +func (g *generator) structSchema(t reflect.Type, doc string) object { + s := obj("type", "object") + if doc != "" { + s.set("description", doc) + } + var props object + for _, f := range fields(t) { + var p any + if f.asString { + p = obj("type", "string") + } else { + p = g.schema(f.typ) + } + if d := g.typeDocOf(f.owner).fields[f.goName]; d != "" { + p = withDescription(p, d) + } + props.set(f.name, p) + } + s.set("properties", props) + return s +} + +// withDescription puts a field's comment on its schema. A reference keeps +// its own description where it is declared; in 3.1 a sibling description +// beside $ref is allowed and is the field's. +func withDescription(p any, d string) any { + o, ok := p.(object) + if !ok { + return p + } + if prev, ok := o.get("description"); ok { + d = d + "\n\n" + prev.(string) + } + o = slices.Clone(o) + o.set("description", d) + return o +} + +// field is one key of a struct's JSON. +type field struct { + name string + goName string + owner reflect.Type // the struct it is declared in, for its comment + typ reflect.Type + asString bool + depth int +} + +// fields lists a struct's JSON keys as encoding/json writes them: exported +// fields, by tag name, "-" left out, embedded structs without a name of +// their own flattened, and a key at a shallower depth winning over the same +// key from deeper in. +func fields(t reflect.Type) []field { + var out []field + var walk func(t reflect.Type, depth int) + walk = func(t reflect.Type, depth int) { + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + name, opts, _ := strings.Cut(tag, ",") + ft := sf.Type + if sf.Anonymous && name == "" { + et := ft + if et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et.Kind() == reflect.Struct && !special(et) { + walk(et, depth+1) + continue + } + } + if !sf.IsExported() { + continue + } + if name == "" { + name = sf.Name + } + out = append(out, field{name: name, goName: sf.Name, owner: t, typ: ft, depth: depth, + asString: slices.Contains(strings.Split(opts, ","), "string")}) + } + } + walk(t, 0) + // The shallowest of each name, in the order they were met. + best := map[string]int{} + for _, f := range out { + if d, ok := best[f.name]; !ok || f.depth < d { + best[f.name] = f.depth + } + } + var kept []field + taken := map[string]bool{} + for _, f := range out { + if f.depth == best[f.name] && !taken[f.name] { + taken[f.name] = true + kept = append(kept, f) + } + } + return kept +} + +// typeDocOf reads a type's comment and its fields' from its package's source. +func (g *generator) typeDocOf(t reflect.Type) typeDoc { + pkg := t.PkgPath() + if pkg != Module && !strings.HasPrefix(pkg, Module+"/") { + return typeDoc{} + } + docs, ok := g.docs[pkg] + if !ok { + docs = parseDocs(filepath.Join(g.root, filepath.FromSlash(strings.TrimPrefix(strings.TrimPrefix(pkg, Module), "/")))) + g.docs[pkg] = docs + } + return docs[t.Name()] +} + +// parseDocs reads the struct types declared in a directory and their +// comments. A directory it cannot read gives no comments rather than an +// error: the document is still right, only quieter. +func parseDocs(dir string) map[string]typeDoc { + out := map[string]typeDoc{} + entries, err := os.ReadDir(dir) + if err != nil { + return out + } + fset := token.NewFileSet() + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, n), nil, parser.ParseComments) + if err != nil { + continue + } + for _, d := range f.Decls { + gd, ok := d.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, sp := range gd.Specs { + ts := sp.(*ast.TypeSpec) + doc := ts.Doc + if doc == nil && len(gd.Specs) == 1 { + doc = gd.Doc + } + td := typeDoc{doc: text(doc), fields: map[string]string{}} + if st, ok := ts.Type.(*ast.StructType); ok { + for _, fl := range st.Fields.List { + c := fl.Doc + if c == nil { + c = fl.Comment + } + for _, name := range fieldNames(fl) { + td.fields[name] = text(c) + } + } + } + out[ts.Name.Name] = td + } + } + } + return out +} + +func fieldNames(fl *ast.Field) []string { + if len(fl.Names) > 0 { + var out []string + for _, n := range fl.Names { + out = append(out, n.Name) + } + return out + } + // Embedded: named by its type. + t := fl.Type + if s, ok := t.(*ast.StarExpr); ok { + t = s.X + } + switch x := t.(type) { + case *ast.Ident: + return []string{x.Name} + case *ast.SelectorExpr: + return []string{x.Sel.Name} + } + return nil +} + +// text is a comment as prose: the comment markers gone, lines within a +// paragraph joined, paragraphs kept apart. +func text(g *ast.CommentGroup) string { + if g == nil { + return "" + } + var paras []string + var cur []string + for _, line := range strings.Split(g.Text(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + if len(cur) > 0 { + paras = append(paras, strings.Join(cur, " ")) + cur = nil + } + continue + } + cur = append(cur, line) + } + if len(cur) > 0 { + paras = append(paras, strings.Join(cur, " ")) + } + return strings.Join(paras, "\n\n") +} + +// --- an object that keeps its keys in order --- + +type kv struct { + k string + v any +} + +// object is a JSON object written in the order its keys were set, so the +// document reads in the order it was meant to — openapi, info, paths — and +// a struct's properties in the order its fields are declared. +type object []kv + +func obj(pairs ...any) object { + var o object + for i := 0; i+1 < len(pairs); i += 2 { + o.set(pairs[i].(string), pairs[i+1]) + } + return o +} + +func (o *object) set(k string, v any) { + for i := range *o { + if (*o)[i].k == k { + (*o)[i].v = v + return + } + } + *o = append(*o, kv{k, v}) +} + +func (o object) get(k string) (any, bool) { + for _, p := range o { + if p.k == k { + return p.v, true + } + } + return nil, false +} + +// MarshalJSON writes the keys in order. +func (o object) MarshalJSON() ([]byte, error) { + var b bytes.Buffer + b.WriteByte('{') + for i, p := range o { + if i > 0 { + b.WriteByte(',') + } + k, err := marshal(p.k) + if err != nil { + return nil, err + } + b.Write(k) + b.WriteByte(':') + v, err := marshal(p.v) + if err != nil { + return nil, err + } + b.Write(v) + } + b.WriteByte('}') + return b.Bytes(), nil +} + +// marshal is json.Marshal without escaping <, > and &, which are common in +// the descriptions and harmless in a document nobody puts in a page. +func marshal(v any) ([]byte, error) { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(b.Bytes(), "\n"), nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/docgen/openapi/openapi_test.go b/internal/docgen/openapi/openapi_test.go new file mode 100644 index 0000000..54e0704 --- /dev/null +++ b/internal/docgen/openapi/openapi_test.go @@ -0,0 +1,163 @@ +package openapi + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate this test") + } + return filepath.Join(filepath.Dir(file), "..", "..", "..") +} + +func generated(t *testing.T) map[string]any { + t.Helper() + b, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("the document is not JSON: %v", err) + } + return doc +} + +// at walks a decoded document by keys. +func at(t *testing.T, v any, keys ...string) any { + t.Helper() + for _, k := range keys { + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("%s: not an object", strings.Join(keys, ".")) + } + v, ok = m[k] + if !ok { + t.Fatalf("no %s (of %s)", k, strings.Join(keys, ".")) + } + } + return v +} + +func TestTheAdminAPIDocumentIsNotStale(t *testing.T) { + // Like the configuration reference: a description of the API that is not + // regenerated describes some earlier week's API, and the server serves the + // committed copy to every client generated from it. + want, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "admin-api.openapi.json")) + if err != nil { + t.Fatalf("docs/admin-api.openapi.json is missing; run go generate ./internal/docgen: %v", err) + } + if string(got) != string(want) { + t.Error("docs/admin-api.openapi.json no longer matches the admin API's routes and types.\n" + + "Run: go generate ./internal/docgen") + } +} + +func TestEveryReferenceInTheDocumentResolves(t *testing.T) { + doc := generated(t) + var walk func(v any) + n := 0 + walk = func(v any) { + switch x := v.(type) { + case map[string]any: + if r, ok := x["$ref"].(string); ok { + n++ + parts := strings.Split(strings.TrimPrefix(r, "#/"), "/") + at(t, doc, parts...) + } + for _, e := range x { + walk(e) + } + case []any: + for _, e := range x { + walk(e) + } + } + } + walk(doc) + if n < 50 { + t.Errorf("only %d references; the schemas are not being shared", n) + } +} + +func TestABodyIsDescribedByItsGoType(t *testing.T) { + doc := generated(t) + scale := at(t, doc, "paths", "/api/v1/models/{name}/scale", "post") + if r := at(t, scale, "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/ScaleRequest" { + t.Errorf("scale's body is %v", r) + } + props := at(t, doc, "components", "schemas", "ScaleRequest", "properties").(map[string]any) + // The tag's name, not the field's. + for _, k := range []string{"min", "max", "idle_timeout"} { + if _, ok := props[k]; !ok { + t.Errorf("ScaleRequest has no %s: %v", k, props) + } + } + // The field's comment is its description, and a duration says its unit. + if d := at(t, props, "max", "description").(string); !strings.Contains(d, "follows Min") { + t.Errorf("max lost its comment: %q", d) + } + if d := at(t, props, "idle_timeout", "description").(string); !strings.Contains(d, "nanoseconds") { + t.Errorf("idle_timeout does not say it is nanoseconds: %q", d) + } + // A type from another package is named with its package. + if r := at(t, doc, "paths", "/api/v1/models", "post", "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/catalog.Model" { + t.Errorf("adding a model takes %v", r) + } +} + +func TestAnEmbeddedStructsFieldsAreItsOwn(t *testing.T) { + // CapacityView embeds bench.Capacity, and encoding/json writes the + // embedded fields at the top level: so must the schema. + doc := generated(t) + props := at(t, doc, "components", "schemas", "CapacityView", "properties").(map[string]any) + for _, k := range []string{"measured_by", "model", "profile"} { + if _, ok := props[k]; !ok { + t.Errorf("CapacityView has no %s: %v", k, props) + } + } +} + +func TestEveryChangeNeedsTheTokenAndNamesItsActor(t *testing.T) { + doc := generated(t) + if s := at(t, doc, "components", "securitySchemes", "adminToken", "scheme"); s != "bearer" { + t.Errorf("scheme %v", s) + } + if len(at(t, doc, "security").([]any)) != 1 { + t.Error("the token is not required document-wide") + } + del := at(t, doc, "paths", "/api/v1/models/{name}", "delete") + found := false + for _, p := range at(t, del, "parameters").([]any) { + if p.(map[string]any)["$ref"] == "#/components/parameters/Actor" { + found = true + } + } + if !found { + t.Error("removing a model does not take X-Llmgw-Actor") + } + if _, ok := at(t, del, "responses").(map[string]any)["401"]; !ok { + t.Error("removing a model does not say it can be refused for the token") + } +} + +func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) { + routes := []adminapi.Route{{Method: "GET", Path: "/api/v1/things/{id}", Summary: "x", Response: adminapi.Result{}}} + if _, err := Generate(repoRoot(t), routes, adminapi.PathParams); err == nil { + t.Error("a parameter nobody described was let through") + } +}