Files
ModelRouter/internal/gateway/server.go

311 lines
11 KiB
Go

// 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(
"&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;",
).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 = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · ModelRouter</title>
<style>
:root{--bg:#f4f6fb;--card:#ffffff;--line:#e2e6ef;--fg:#1c2333;--muted:#6b7390;--accent:#3f6ef5;--accent-h:#2f5ae0;--err:#e5484d}
*{box-sizing:border-box}
body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(160deg,#f4f6fb 0%%,#e8edf8 100%%);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:36px 34px;width:340px;box-shadow:0 12px 40px rgba(31,45,90,.10)}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#3f6ef5,#6a8ffb);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px}
h1{font-size:18px;margin:0;letter-spacing:.2px}
.sub{color:var(--muted);font-size:13px;margin:4px 0 22px}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
input{width:100%%;background:#fbfcfe;border:1px solid var(--line);border-radius:8px;padding:10px 12px;font:inherit;color:var(--fg);outline:none;transition:border .15s,box-shadow .15s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(63,110,245,.15)}
button{margin-top:16px;width:100%%;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:11px;font:inherit;font-weight:600;cursor:pointer;transition:background .15s}
button:hover{background:var(--accent-h)}
#msg{color:var(--err);margin-top:12px;min-height:18px;font-size:13px}
.foot{margin-top:18px;text-align:center;font-size:12px;color:var(--muted)}
.foot a{color:var(--muted);text-decoration:none;border-bottom:1px dashed var(--line)}
</style></head>
<body><div class="card">
<div class="brand"><div class="logo">M</div><div>
<h1 data-i="title">ModelRouter</h1>
<div class="sub" data-i="sub">统一 LLM 网关 · 登录</div></div></div>
<input id="key" type="password" data-i-ph="ph" placeholder="Gateway API Key" autocomplete="off">
<button onclick="login()" data-i="btn">登录</button>
<div id="msg"></div>
<div class="foot"><a data-i="lang" href="?lang=en&continue=%[1]s">English</a></div>
</div>
<script>
var LANG='%[3]s';
var CONT='%[2]s';
var I={zh:{title:'ModelRouter',sub:'统一 LLM 网关 · 登录',ph:'输入网关 API Key',btn:'登录',lang:'English'},
en:{title:'ModelRouter',sub:'Unified LLM Gateway · Sign in',ph:'Enter gateway API key',btn:'Sign in',lang:'中文'}};
function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title;
document.querySelector('[data-i=sub]').textContent=t.sub;
document.querySelector('[data-i=ph]').placeholder=t.ph;
document.querySelector('[data-i=btn]').textContent=t.btn;
document.querySelector('[data-i=lang]').textContent=t.lang;
document.documentElement.lang=LANG==='zh'?'zh':'en';}
document.querySelector('[data-i=lang]').onclick=function(e){e.preventDefault();
location.href='/login?lang='+(LANG==='zh'?'en':'zh')+'&continue='+encodeURIComponent(CONT);};
async function login(){
var key=document.getElementById('key').value.trim();
if(!key){document.getElementById('msg').textContent=(LANG==='zh'?'请输入 Key':'Enter a key');return;}
var r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
var j=await r.json().catch(function(){return{};});
if(r.ok){location.href=CONT||'/';}
else{document.getElementById('msg').textContent=(j.error&&j.error.message)||(LANG==='zh'?'登录失败':'Login failed');}
}
document.getElementById('key').addEventListener('keydown',function(e){if(e.key==='Enter')login();});
apply();
</script></body></html>`
// 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 !g.apiKeys[body.Key] {
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()
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
}
keys := make([]string, 0, len(g.apiKeys))
for k := range g.apiKeys {
keys = append(keys, k)
}
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": keys,
})
}
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)
}
}