package gateway import ( "encoding/json" "errors" "net/http" "os" "path/filepath" "strings" "llmsproxy/internal/lua" ) var ( errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot") errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins") ) // Plugin management API. // // GET /api/plugins list loaded plugins + their hooks/UI/errors // POST /api/plugins upload/replace one plugin (.lua), hot-applied // DELETE /api/plugins/{name} remove a plugin // GET /api/plugins/{name}/state the plugin's own published state // PUT /api/plugins/{name}/state replace that state (admin only) // // Why state is a first-class endpoint: request_end is the LAST stage, so a // hook's return value has no downstream consumer inside the gateway. A plugin // that accumulates numbers (the billing plugin does exactly this) therefore // keeps them in its own Lua state and serves them here, which is what its UI // component fetches. This keeps plugin data clearly separated from the // gateway's own stats — see the accounting note in docs/plugins.md: the // gateway's quota accounting stays authoritative, a plugin only reports. // handlePluginUI serves the merged UI extensions the WebUI injects at boot. // // It is a single GET (not per-plugin) because the browser needs ALL extensions // before it can build the sidebar: a page contributed by one plugin and an // element contributed by another land in the same payload, and fetching them // separately would mean the sidebar has to be rebuilt as each arrives. // // Served to any authenticated role: the WebUI is authenticated before it asks, // and a plugin's own widgets need to render for the user whose key they show. func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } ps := g.core.Plugins() if ps == nil { writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}}) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "ui": ps.UI(), "stages": []string{ string(lua.StageRequestStart), string(lua.StageRouted), string(lua.StageRequestEnd), }, }) } func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { ps := g.core.Plugins() if ps == nil { writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}}) return } path := strings.TrimPrefix(r.URL.Path, "/api/plugins") path = strings.Trim(path, "/") // /api/plugins/{name}/state if strings.HasSuffix(path, "/state") { name := strings.TrimSuffix(path, "/state") if name == "" { writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required") return } g.handlePluginState(w, r, name) return } if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return } if path == "" { switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]interface{}{ "plugins": ps.List(), "hook_errors": ps.HookErrors(), "plugin_dir": g.pluginDir(), }) case http.MethodPost: var body struct { Name string `json:"name"` Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) return } if err := g.installPlugin(body.Name, body.Code); err != nil { writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name}) default: writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") } return } switch r.Method { case http.MethodDelete: if err := g.removePlugin(path); err != nil { writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) case http.MethodGet: // A GET on a specific plugin is almost always a client that meant to // delete it but let fetch default to GET; name the verb. writeError(w, http.StatusNotFound, "not_found", "plugin source not exposed; use DELETE /api/plugins/"+path+" to remove it, "+ "or GET /api/plugins/"+path+"/state for its published state") default: writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") } } // handlePluginState serves GET (read state) and PUT (replace state). func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) { ps := g.core.Plugins() switch r.Method { case http.MethodGet: // Any role may read: plugin state is reporting data (cost, counts), // and the caller has already been authenticated. Admin-only would stop // a user key's own billing widget from rendering. writeJSON(w, http.StatusOK, map[string]interface{}{ "plugin": name, "state": ps.State(name), }) case http.MethodPut: if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return } var state interface{} if err := json.NewDecoder(r.Body).Decode(&state); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) return } if err := ps.SetState(name, state); err != nil { writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) default: writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") } } func (g *Gateway) pluginDir() string { if c := g.core.Config(); c != nil { return c.PluginDir } return "" } // installPlugin writes a plugin to disk and hot-loads it. A syntax error is // returned to the caller AND the file is left on disk so the operator can fix // it, matching how adapters behave (the file is authoritative once present). func (g *Gateway) installPlugin(name, code string) error { if name == "" { return errPluginName } if strings.ContainsAny(name, `/\.`) { return errPluginName } dir := g.pluginDir() if dir == "" { return errNoPluginDir } if err := os.MkdirAll(dir, 0755); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil { return err } return g.core.Plugins().LoadSource(name, code) } func (g *Gateway) removePlugin(name string) error { if g.pluginDir() == "" { return errNoPluginDir } _ = os.Remove(filepath.Join(g.pluginDir(), name+".lua")) return g.core.Plugins().Unload(name) }