mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
311 lines
11 KiB
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(
|
|
"&", "&", "<", "<", ">", ">", `"`, """, "'", "'",
|
|
).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)
|
|
}
|
|
} |