package gateway // Agent-facing management API. // // The Web UI has always driven the gateway through /api/*, so the management // surface already exists. What it lacked was anything an agent could *rely* on: // no way to discover the contract, no single call that answers "what is the // current state", and per-endpoint response shapes that make scripting brittle. // // This file adds a versioned, self-describing facade under /api/v1 without // touching the existing endpoints, so the Web UI keeps working unchanged while // agents get a stable contract: // // GET /api/v1 — machine-readable index of every endpoint // GET /api/v1/overview — one call: sources + auto chain + keys + health // GET /api/v1/sources — sources, credentials masked // GET /api/v1/sources/{name} — one source // GET /api/v1/auto — scheduling chain + live slot states // GET /api/v1/keys — key metadata (never the secret) // GET /api/v1/models — every model id the gateway can route // GET /api/v1/health — per-source health, no auth needed beyond the key // // Auth is the same gateway key as everywhere else (Authorization: Bearer, or // ?api_key=, or the gw_key cookie). Read endpoints accept any role; writes // still require admin, enforced by the same middleware the UI goes through. import ( "errors" "net/http" "net/url" "sort" "strings" "llmsproxy/internal/config" ) // apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original // switch stays readable and the versioned surface can grow on its own. func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1") path = strings.Trim(path, "/") switch { case path == "": g.apiV1Index(w, r) case path == "overview": g.apiV1Overview(w, r) case path == "health": g.apiV1Health(w, r) case path == "models": g.apiV1Models(w, r) case path == "sources": if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } writeJSON(w, http.StatusOK, map[string]interface{}{"sources": maskSources(g.core.Sources())}) case strings.HasPrefix(path, "sources/"): name, err := decodePathSegment(strings.TrimPrefix(path, "sources/")) if err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT/DELETE on /api/sources/{name} to modify a source") return } for _, s := range g.core.Sources() { if s.Name == name { // ?reveal=credentials is admin-only and is what the Web UI's // edit dialog uses: a form that round-trips a source must be // able to show the current key, otherwise saving an unrelated // field would blank it. Everything else stays masked. if r.URL.Query().Get("reveal") == "credentials" { if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required to reveal credentials") return } writeJSON(w, http.StatusOK, map[string]interface{}{"source": s}) return } writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)}) return } } writeError(w, http.StatusNotFound, "not_found", "no such source: "+name) case path == "auto": if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "rules": g.core.AutoRules(), "image_rules": g.core.AutoImageRules(), "states": g.core.AutoSlotStates(), }) case path == "keys": if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return } if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST/PUT/DELETE on /api/keys to manage keys") return } keys := g.core.ListKeys() out := make([]map[string]interface{}, 0, len(keys)) for _, k := range keys { out = append(out, map[string]interface{}{ "name": k.Name, "role": k.Role, "models": k.Models, "note": k.Note, "created_at": k.CreatedAt, "seed": k.Seed, // The secret itself is never echoed. An operator that needs it // already has it from creation time or from config.yaml. "key_prefix": maskKey(k.Key), }) } writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out}) default: g.apiV1Index(w, r) } } // apiV1Index is the discovery document: it names every endpoint with its // method, auth requirement and purpose, so an agent does not have to scrape the // HTML to find out what it can call. func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { type ep struct { Method string `json:"method"` Path string `json:"path"` Auth string `json:"auth"` Summary string `json:"summary"` WriteEffect string `json:"writes,omitempty"` } index := map[string]interface{}{ "api_version": "v1", "description": "llmsproxy management API. Authenticate with a gateway key: " + "'Authorization: Bearer ', '?api_key=', or the gw_key cookie. " + "Read endpoints accept any role; mutations require an admin key.", "discovery": []ep{ {Method: "GET", Path: "/api/v1", Auth: "any", Summary: "this document"}, {Method: "GET", Path: "/api/v1/overview", Auth: "any", Summary: "current state in one call: sources, auto chain, key count, source health"}, {Method: "GET", Path: "/api/v1/health", Auth: "any", Summary: "per-source health snapshot only"}, {Method: "GET", Path: "/api/v1/models", Auth: "any", Summary: "every model id the gateway can route, grouped by owning source"}, {Method: "GET", Path: "/api/v1/sources", Auth: "any", Summary: "all sources with credentials masked"}, {Method: "GET", Path: "/api/v1/sources/{name}", Auth: "any", Summary: "one source"}, {Method: "GET", Path: "/api/v1/auto", Auth: "any", Summary: "AUTO scheduling chain and live per-slot state"}, {Method: "GET", Path: "/api/v1/keys", Auth: "admin", Summary: "gateway key metadata; secrets are never returned"}, {Method: "GET", Path: "/api/sources", Auth: "admin", Summary: "raw source list (includes api_key)"}, {Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source", WriteEffect: "writes config.yaml (api_key sealed at rest)"}, {Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source", WriteEffect: "writes config.yaml"}, {Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source", WriteEffect: "writes config.yaml"}, {Method: "GET", Path: "/api/auto", Auth: "any", Summary: "scheduling chain (readable by any role)"}, {Method: "PUT", Path: "/api/auto", Auth: "admin", Summary: "replace the scheduling chain", WriteEffect: "writes config.yaml (the AUTO chain lives here)"}, {Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"}, {Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key", WriteEffect: "writes config.yaml"}, {Method: "DELETE", Path: "/api/keys/{name}", Auth: "admin", Summary: "delete a gateway key", WriteEffect: "writes config.yaml"}, {Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"}, {Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"}, {Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"}, {Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"}, {Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"}, {Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"}, }, "conventions": map[string]interface{}{ "errors": "{ \"error\": { \"type\": , \"message\": } }", "path_escape": "URL-encode source and key names; {name} is a single path segment", "idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name", "config_truth": "all configuration lives in config.yaml; API writes are persisted immediately", "credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " + "returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " + "that round-trips a source must show the current key or saving another field would blank it.", }, } writeJSON(w, http.StatusOK, index) } // apiV1Overview is the "what is the current state" call an agent makes first. // One round trip instead of five. func (g *Gateway) apiV1Overview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } sources := g.core.Sources() names := make([]string, 0, len(sources)) modelCount := 0 for _, s := range sources { names = append(names, s.Name) modelCount += len(s.Models) } keys := g.core.ListKeys() adminCount, userCount := 0, 0 for _, k := range keys { if k.Role == "admin" { adminCount++ } else { userCount++ } } writeJSON(w, http.StatusOK, map[string]interface{}{ "api_version": "v1", "summary": map[string]interface{}{ "source_count": len(sources), "model_count": modelCount, "gateway_key_count": len(keys), "admin_key_count": adminCount, "user_key_count": userCount, "auto_slots": len(g.core.AutoRules()), }, "sources": maskSources(sources), "auto": g.core.AutoRules(), "auto_image": g.core.AutoImageRules(), "health": g.sourceHealthBrief(), "caller": map[string]interface{}{ "role": reqRole(r.Context()), "key": maskKey(reqKey(r.Context())), }, }) } func (g *Gateway) apiV1Health(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.sourceHealthBrief()}) } // apiV1Models lists routable model ids per source, which is what an agent needs // to build a valid request — /v1/models flattens them and hides the owner. func (g *Gateway) apiV1Models(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } bySource := map[string][]string{} var all []string for _, s := range g.core.Sources() { for _, m := range s.Models { bySource[s.Name] = append(bySource[s.Name], m.ID) all = append(all, m.ID) } } sort.Strings(all) all = dedupeStrings(all) writeJSON(w, http.StatusOK, map[string]interface{}{ "count": len(all), "models": all, "by_source": bySource, "note": "request a model as \":\" to pin one source, or the bare id to let the gateway choose", }) } // ---- helpers ---- // maskSources returns sources with credentials replaced by a presence marker. // The UI still uses /api/sources (which returns real values, because its edit // form round-trips them); this is the safe view for programmatic callers. func maskSources(srcs []config.Source) []map[string]interface{} { out := make([]map[string]interface{}, 0, len(srcs)) for _, s := range srcs { out = append(out, maskSource(s)) } return out } func maskSource(s config.Source) map[string]interface{} { return map[string]interface{}{ "name": s.Name, "base_url": s.BaseURL, "adapter": s.Adapter, "endpoint": s.Endpoint, "image_endpoint": s.ImageEndpoint, "api_key": maskKey(s.APIKey), "api_key_set": s.APIKey != "", "models": s.Models, "headers": maskHeaders(s.Headers), "proxy_url": s.ProxyURL, "meta": s.Meta, "temperature": s.Temperature, "max_tokens": s.MaxTokens, "max_concurrent": s.MaxConcurrent, "rpm": s.RPM, } } func maskKey(k string) string { if k == "" { return "" } if strings.HasPrefix(k, "enc:v1:") { return "(sealed)" } if len(k) <= 10 { return k[:2] + "…" } return k[:6] + "…" + k[len(k)-4:] } func maskHeaders(h map[string]string) map[string]string { if h == nil { return nil } out := make(map[string]string, len(h)) for k, v := range h { out[k] = maskKey(v) } return out } // sourceHealthBrief reuses the same registry status the UI's /api/status shows, // so the agent view and the UI view cannot drift apart. Recent-traffic counters // come from the same stats window, because a source that is actually serving // traffic must never look down just because a probe was rate-limited. func (g *Gateway) sourceHealthBrief() []map[string]interface{} { sts := g.core.Registry().Status() recent := g.stats.SourceRecent(300) avgs := g.stats.SourceAverages(300) for i := range sts { if v, ok := recent[sts[i].Name]; ok { sts[i].RecentOK = v[0] sts[i].RecentErr = v[1] } if a, ok := avgs[sts[i].Name]; ok { sts[i].AvgFirstByteMs = a.AvgFirstByteMs sts[i].AvgTokPerS = a.AvgTokPerS } } out := make([]map[string]interface{}, 0, len(sts)) for _, s := range sts { row := map[string]interface{}{ "name": s.Name, "adapter": s.Adapter, "healthy": s.Healthy, "available": s.Available, "live_available": s.LiveAvailable, "model_count": len(s.Models), } if s.LastError != "" { row["last_error"] = s.LastError } if s.LastChecked > 0 { row["last_checked"] = s.LastChecked } if s.RecentOK > 0 || s.RecentErr > 0 { row["recent_ok"] = s.RecentOK row["recent_err"] = s.RecentErr } if s.AvgFirstByteMs > 0 { row["avg_first_byte_ms"] = s.AvgFirstByteMs } if s.AvgTokPerS > 0 { row["avg_tok_per_s"] = s.AvgTokPerS } if s.FailCount > 0 { row["fail_count"] = s.FailCount } if s.Permanent { row["permanent"] = true } out = append(out, row) } return out } // decodePathSegment URL-decodes one path segment and rejects an empty result, // so a name containing a slash cannot be silently mis-resolved. func decodePathSegment(seg string) (string, error) { s, err := url.PathUnescape(seg) if err != nil { return "", err } if s == "" { return "", errEmptySegment } return s, nil } func dedupeStrings(in []string) []string { out := in[:0] var last string for i, s := range in { if i == 0 || s != last { out = append(out, s) } last = s } return out } // errEmptySegment marks a path like /api/v1/sources/ with no name after it. var errEmptySegment = errors.New("empty path segment")