// Package gateway exposes an OpenAI-compatible HTTP API over the provider // registry: POST /v1/chat/completions (SDK + SSE), POST /v1/images/generations, // GET /v1/models, protected by shared gateway API keys, plus a web UI and // management API for adapters and sources. package gateway import ( "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "net/url" "strings" "llmsproxy/internal/core" ) //go:embed ui/* var uiFS embed.FS // Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI. type Gateway struct { core *core.Core apiKeys map[string]bool ui http.Handler } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { keys := map[string]bool{} for _, k := range gatewayKeys { if k != "" { keys[k] = true } } sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err } return &Gateway{ core: c, apiKeys: keys, ui: http.FileServer(http.FS(sub)), }, nil } func (g *Gateway) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // login entry point + login/logout API are the only unauthenticated routes if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" { g.routes(w, r) return } g.auth(http.HandlerFunc(g.routes)).ServeHTTP(w, r) }) } func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/v1/chat/completions": g.handleChat(w, r) case r.URL.Path == "/v1/images/generations": g.handleImage(w, r) case r.URL.Path == "/v1/models": g.handleModels(w, r) case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"): g.handleAdaptersAPI(w, r) case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"): g.handleSourcesAPI(w, r) case r.URL.Path == "/api/chat": g.handleChat(w, r) case r.URL.Path == "/api/status": g.handleStatusAPI(w, r) case r.URL.Path == "/login": g.handleLogin(w, r) case r.URL.Path == "/api/login": g.handleLoginAPI(w, r) case r.URL.Path == "/api/logout": g.handleLogoutAPI(w, r) default: g.serveUI(w, r) } } func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) { // serve index.html directly for the root path (FileServer would 301 it) if r.URL.Path == "/" || r.URL.Path == "/ui" { data, err := uiFS.ReadFile("ui/index.html") if err != nil { http.Error(w, "ui missing", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) return } g.ui.ServeHTTP(w, r) } func (g *Gateway) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(g.apiKeys) == 0 { next.ServeHTTP(w, r) return } key := "" if h := r.Header.Get("Authorization"); h != "" { parts := strings.SplitN(h, " ", 2) if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { key = parts[1] } } if key == "" { key = r.URL.Query().Get("api_key") } if key == "" { if c, err := r.Cookie("gw_key"); err == nil { key = c.Value } } if !g.apiKeys[key] { if isAPIPath(r.URL.Path) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key") return } // browser navigation to UI pages -> login page http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } next.ServeHTTP(w, r) }) } // isAPIPath reports whether the request targets a JSON API endpoint that // should answer 401 instead of redirecting to the login page. func isAPIPath(p string) bool { return strings.HasPrefix(p, "/v1/") || strings.HasPrefix(p, "/api/") } // handleLogoutAPI clears the session cookie and redirects to the login page. func (g *Gateway) handleLogoutAPI(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "gw_key", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/login", http.StatusFound) } // handleLogin serves the login page (unauthenticated). Supports a // ?continue= path to return to after a successful login and a ?lang=zh|en // toggle for i18n. func (g *Gateway) handleLogin(w http.ResponseWriter, r *http.Request) { continuePath := r.URL.Query().Get("continue") if continuePath == "" || !strings.HasPrefix(continuePath, "/") || strings.HasPrefix(continuePath, "//") { continuePath = "/" } lang := strings.ToLower(r.URL.Query().Get("lang")) if lang != "en" { lang = "zh" } // escape for embedding in HTML attribute and single-quoted JS string htmlCont := strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ).Replace(continuePath) jsCont := strings.NewReplacer("\\", "\\\\", "'", "\\'", "\n", "\\n").Replace(continuePath) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, loginPageHTML, htmlCont, jsCont, lang) } const loginPageHTML = `