// 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 ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "net/url" "strings" "llmsproxy/internal/config" "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 ui http.Handler stats *Stats } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err } return &Gateway{ core: c, ui: http.FileServer(http.FS(sub)), stats: NewStats(3000), }, 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 == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"): g.handleStatsAPI(w, r) case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"): g.handleKeysAPI(w, r) case r.URL.Path == "/api/auto": g.handleAutoAPI(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.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 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) { 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 } } rec, ok := g.core.FindKey(key) if !ok { 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.WithContext(withAuth(r.Context(), key, rec.Role))) }) } // authCtx carries the authenticated gateway key and its role. type authCtx struct { key string role string } type authCtxKeyT struct{} func withAuth(ctx context.Context, key, role string) context.Context { return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role}) } // reqKey returns the authenticated gateway key id (masked suffix for display). func reqKey(ctx context.Context) string { if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { return a.key } return "" } // reqRole returns the authenticated key role ("admin" or "user"). func reqRole(ctx context.Context) string { if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { return a.role } return "user" } // keyID returns a short stable id for a gateway key (last 6 chars). func keyID(k string) string { if len(k) <= 6 { return k } return "***" + k[len(k)-6:] } // 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 = ` 登录 · ModelRouter

ModelRouter

统一 LLM 网关 · 登录
English
` // handleLoginAPI validates the gateway key and issues a session cookie. func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } var body struct { Key string `json:"key"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json") return } if _, ok := g.core.FindKey(body.Key); !ok { writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key") return } http.SetCookie(w, &http.Cookie{ Name: "gw_key", Value: body.Key, Path: "/", MaxAge: 86400 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } models := g.core.Registry().ModelList() if allow := g.allowedModels(r.Context()); allow != nil { models = intersectModels(models, allow) } type modelObj struct { ID string `json:"id"` Object string `json:"object"` } objs := make([]modelObj, 0, len(models)) for _, m := range models { objs = append(objs, modelObj{ID: m, Object: "model"}) } writeJSON(w, http.StatusOK, map[string]interface{}{ "object": "list", "data": objs, }) } func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { host := r.Host if host == "" { host = g.core.Listen() } if strings.HasPrefix(host, ":") { host = "127.0.0.1" + host } ks := make([]config.GWKey, 0, len(g.core.ListKeys())) for _, k := range g.core.ListKeys() { ks = append(ks, config.GWKey{ Key: k.Key, Role: k.Role, Name: k.Name, }) } writeJSON(w, http.StatusOK, map[string]interface{}{ "default_model": g.core.DefaultModel(), "models": g.core.Registry().ModelList(), "sources": g.core.Registry().Status(), "adapters": g.core.ListAdapters(), "base_url": "http://" + host + "/v1", "gateway_keys": ks, }) } func writeError(w http.ResponseWriter, code int, errType, msg string) { writeJSON(w, code, map[string]interface{}{ "error": map[string]interface{}{"type": errType, "message": msg}, }) } func writeJSON(w http.ResponseWriter, code int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("[gateway] write json: %v", err) } }