// 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" "sync" "time" "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 probeMu sync.Mutex lastProbe time.Time } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err } st := NewStats(10000) if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" { st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl") } return &Gateway{ core: c, ui: http.FileServer(http.FS(sub)), stats: st, }, nil } func (g *Gateway) Handler() http.Handler { // access log wraps all requests (auth, unauthenticated, login). inner := 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) }) return g.logAccess(inner) } // logAccess wraps an http.Handler so every response is written to the audit // file as an access event (method, path, status, latency, key). func (g *Gateway) logAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t0 := time.Now() sr := &statusRecorder{ResponseWriter: w} 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 } } next.ServeHTTP(sr, r) g.stats.AppendAudit("access", map[string]interface{}{ "method": r.Method, "path": r.URL.RequestURI(), "status": sr.code, "lat_ms": time.Since(t0).Milliseconds(), "key": keyID(key), }) }) } type statusRecorder struct { http.ResponseWriter code int } func (s *statusRecorder) WriteHeader(code int) { if s.code == 0 { s.code = code } s.ResponseWriter.WriteHeader(code) } func (s *statusRecorder) Write(b []byte) (int, error) { if s.code == 0 { s.code = http.StatusOK } return s.ResponseWriter.Write(b) } // Flush forwards to the underlying writer so SSE handlers can stream // incrementally through the access-log wrapper. Without this method the // w.(http.Flusher) assertion inside the streaming chat handlers fails (the // embedded ResponseWriter interface does not carry Flush into the method set), // and every chunk stays buffered until the response ends. func (s *statusRecorder) Flush() { if f, ok := s.ResponseWriter.(http.Flusher); ok { f.Flush() } } 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/status/reset": g.handleResetHealth(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, }) } // ensureProbe triggers a live source probe at most once every 30s. // The probe runs asynchronously so a slow/stuck upstream never blocks the // status response — reachability data is eventually-consistent and the UI // polls /api/stats every 3s anyway, so the next refresh picks it up. func (g *Gateway) ensureProbe(ctx context.Context) { g.probeMu.Lock() due := time.Since(g.lastProbe) > 30*time.Second if due { g.lastProbe = time.Now() } g.probeMu.Unlock() if !due { return } go func() { probeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() g.core.Registry().ProbeAll(probeCtx) }() } // handleResetHealth (admin) clears the per-source backoff state so a fixed // upstream or an edited AUTO priority chain becomes schedulable immediately. func (g *Gateway) handleResetHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return } g.core.ResetHealth() g.stats.AppendAudit("config", map[string]interface{}{"action": "reset_health", "key": keyID(reqKey(r.Context()))}) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { g.ensureProbe(r.Context()) host := r.Host if host == "" { host = g.core.Listen() } if strings.HasPrefix(host, ":") { host = "127.0.0.1" + host } // gateway_keys: return only the current authenticated user's key so the // connection snippet on the home page always shows the correct key // (previously every user saw the first admin key in the list). myKey := reqKey(r.Context()) ks := make([]config.GWKey, 0, len(g.core.ListKeys())) for _, k := range g.core.ListKeys() { if k.Key == myKey { ks = append(ks, config.GWKey{ Key: k.Key, Role: k.Role, Name: k.Name, }) break } } scheme := "http" if r.TLS != nil { scheme = "https" } baseURL := g.core.PublicBaseURL() if baseURL == "" { baseURL = scheme + "://" + host + "/v1" } models := g.core.Registry().ModelList() if reqRole(r.Context()) != "admin" { models = g.scopedModelList(r.Context(), models) } resp := map[string]interface{}{ "default_model": g.core.DefaultModel(), "models": models, "base_url": baseURL, "gateway_keys": ks, } if reqRole(r.Context()) == "admin" { sts := g.core.Registry().Status() recent := g.stats.SourceRecent(300) for i := range sts { if v, ok := recent[sts[i].Name]; ok { sts[i].RecentOK = v[0] sts[i].RecentErr = v[1] } } resp["sources"] = sts resp["adapters"] = g.core.ListAdapters() } writeJSON(w, http.StatusOK, resp) } // scopedModelList filters the full model set to only those allowed by the // request's gateway key (used for users with a restricted model scope). func (g *Gateway) scopedModelList(ctx context.Context, full []string) []string { allow := g.allowedModels(ctx) if allow == nil { return full } out := make([]string, 0, len(allow)) for _, m := range allow { if m.Model == "" { continue } out = append(out, m.Model) } return out } 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) } }