Files
Ilker ManapandClaude Opus 5.5 fd3c7bda32
CI / test (push) Failing after 6m12s
The client API described in OpenAPI, and both APIs in Swagger UI
/v1/openapi.json is the API clients use — OpenAI's endpoints, Anthropic's,
and what llmgw adds — in OpenAPI 3.1, generated from internal/clientapi's
route table by the generator the admin API's document comes from, now
told each document's title, server, token and error. A test reads the
router's mux.HandleFunc calls and refuses a route described and not
mounted, or mounted and not described. Asked for before there is a key,
so it needs none.

/docs/api shows it in Swagger UI 5.33.1 (Apache 2.0, served from the
gateway as the page's policy requires): Authorize takes a key, Try it out
sends to this gateway. Staff are offered the admin API's document too,
from /docs/api/admin.json, which nobody else is given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:22:48 +02:00

748 lines
22 KiB
Go

// 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/clientapi"
"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"
// Doc is what one document says of itself and its API: the two APIs llmgw
// has, the admin listener's and the client's, are described by one
// generator from their route tables.
type Doc struct {
Title, Description string
// ServerURL and ServerDescription are where it is served by default.
ServerURL, ServerDescription string
// Scheme is the bearer token's name in the document, SchemeDescription
// what it is.
Scheme, SchemeDescription string
// Error is a value of the type every refusal answers with.
Error any
// Prefix is taken off paths to name operations and tags.
Prefix string
// Home is the package whose types are named alone; others are named
// with their package.
Home string
// Actor is whether changes take the admin API's X-Llmgw-Actor header.
Actor bool
}
// Admin is the admin API's document.
var Admin = Doc{
Title: "llmgw admin API", Description: infoDescription,
ServerURL: "http://127.0.0.1:9090", ServerDescription: "The admin listener at its default address, admin_listen.",
Scheme: "adminToken",
SchemeDescription: "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.",
Error: adminapi.ErrorView{}, Prefix: "/api/v1/", Home: reflect.TypeOf(adminapi.Route{}).PkgPath(), Actor: true,
}
// Client is the client API's document: the API on :8080 clients use.
var Client = Doc{
Title: "llmgw client API", Description: clientDescription,
ServerURL: "http://localhost:8080", ServerDescription: "A gateway at its default address, listen.",
Scheme: "apiKey",
SchemeDescription: "An API key, sk-llmgw-…, made with `llmgw keys create` or on the portal's profile page. " +
"Sent as Authorization: Bearer KEY; /v1/messages also takes it as x-api-key.",
Error: clientapi.Error{}, Prefix: "/v1/", Home: reflect.TypeOf(clientapi.Error{}).PkgPath(),
}
const clientDescription = "OpenAI's API, as llmgw serves it, and what llmgw adds: pictures and 3D models from " +
"ComfyUI, knowledge bases, memory notes, stored conversations.\n\n" +
"The SDKs of OpenAI and Anthropic work against it unchanged: set the base URL to the gateway's /v1 and the " +
"key to an llmgw key. A request carries more than is listed here: what the gateway does not read goes to " +
"the engine as it came. Headers llmgw reads: x-llmgw-thread (continue a stored conversation), " +
"x-llmgw-knowledge (answer from these collections), x-llmgw-tags (tags kept with the usage), " +
"x-session-id (stay on one replica). Headers it sends back: x-request-id, x-llmgw-served-model, " +
"x-llmgw-hardware, x-llmgw-seed, and Retry-After when waiting helps.\n\n" +
"Generated from the route table in internal/clientapi; see docs/clients.md."
// Generate renders the admin API's document, reading doc comments from the
// repository at root.
func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
return GenerateDoc(root, Admin, routes, pathParams)
}
// GenerateDoc renders one API's document.
func GenerateDoc(root string, d Doc, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
errorType := reflect.TypeOf(d.Error)
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}, d: d}
// 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[g.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))
}
components := obj(
"securitySchemes", obj(d.Scheme, obj(
"type", "http",
"scheme", "bearer",
"description", d.SchemeDescription,
)),
)
if d.Actor {
components.set("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"),
)))
}
components.set("responses", obj(
"Error", obj("description", "Refused: the error says why.",
"content", obj("application/json", obj("schema", errorSchema))),
"Unauthorized", obj("description", "The token is missing or wrong.",
"content", obj("application/json", obj("schema", errorSchema))),
))
components.set("schemas", schemas)
doc := obj(
"openapi", "3.1.0",
"info", obj(
"title", d.Title,
"version", "v1",
"description", d.Description,
),
"servers", []any{obj("url", d.ServerURL, "description", d.ServerDescription)},
"security", []any{obj(d.Scheme, []any{})},
"tags", tagList,
"paths", pathsObj,
"components", components,
)
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", g.operationID(r.Method, r.Path))
op.set("summary", r.Summary)
if r.Description != "" {
op.set("description", r.Description)
}
op.set("tags", []any{g.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 the path parameters", 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 g.d.Actor && 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 {
ct := "application/json"
if r.Form {
ct = "multipart/form-data"
}
body := obj("required", !r.OptionalBody,
"content", obj(ct, obj("schema", g.schema(reflect.TypeOf(r.Request)))))
op.set("requestBody", body)
}
status := r.Status
if status == 0 {
status = 200
}
var content object
switch {
case r.Binary != "":
content = obj(r.Binary, obj("schema", obj("type", "string", "format", "binary")))
case r.Response == nil:
content = obj("application/json", obj("schema", obj()))
default:
content = obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response))))
}
if r.CSV {
content.set("text/csv", obj("schema", obj("type", "string")))
}
if r.Stream {
content.set("text/event-stream", obj("schema", obj("type", "string",
"description", "With stream: true, server-sent events: one JSON chunk per data: line, then data: [DONE].")))
}
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 (g *generator) operationID(method, p string) string {
var b strings.Builder
b.WriteString(strings.ToLower(method))
for _, s := range strings.Split(strings.TrimPrefix(p, g.d.Prefix), "/") {
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 (g *generator) tagOf(p string) string {
first, _, _ := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(p, g.d.Prefix), "/"), "/")
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
d Doc
}
var (
timeType = reflect.TypeOf(time.Time{})
durationType = reflect.TypeOf(time.Duration(0))
rawType = reflect.TypeOf(json.RawMessage{})
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 := g.d.Home
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
}