mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
feat: LuaJIT worker-pool VM, multimodal/disable-thinking passthrough, WebUI redesign, DeepSeek V4
This commit is contained in:
@ -7,9 +7,11 @@ package gateway
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/core"
|
||||
@ -44,7 +46,14 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
|
||||
}
|
||||
|
||||
func (g *Gateway) Handler() http.Handler {
|
||||
return g.auth(http.HandlerFunc(g.routes))
|
||||
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) {
|
||||
@ -59,8 +68,16 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@ -97,14 +114,146 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
|
||||
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] {
|
||||
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api 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")
|
||||
@ -126,11 +275,24 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user