Files
zhanlu_proxy/internal/server/server.go
T
2026-08-19 20:23:21 +08:00

1749 lines
72 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bufio"
"context"
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
)
type Server struct {
cfg config.Config
mux *http.ServeMux
loginSession string
st *store.Store
statsEnabled bool
}
func New(cfg config.Config, st *store.Store) http.Handler {
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
if cfg.LoginPassword != "" {
s.loginSession = randomSessionToken()
}
s.routes()
return s.mux
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /", s.index)
s.mux.HandleFunc("GET /healthz", s.healthz)
s.mux.HandleFunc("GET /login", s.loginPage)
s.mux.HandleFunc("GET /admin", s.adminPage)
s.mux.HandleFunc("GET /admin/login", s.redirectAdmin)
s.mux.HandleFunc("GET /admin/stats", s.redirectAdmin)
s.mux.HandleFunc("POST /api/login", s.passwordLogin)
s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback))
s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode))
s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode))
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.OpenAIAPIKey != "" {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if got != s.cfg.OpenAIAPIKey {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
return
}
}
next(w, r)
}
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if s.hasLoginSession(r) {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
if s.cfg.LoginPassword == "" {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "PasswordEnabled": true})
}
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusFound)
}
// adminPage renders the unified management console: a single page with tabs for
// token statistics (default) and credential (phone code) login. It requires an
// authenticated management session; without one it bounces to /login.
func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
var summary *stats.Summary
enabled := s.statsEnabled
if s.st != nil {
if sm, err := s.st.Stats(stats.Query{}); err == nil {
summary = sm
}
}
if summary == nil {
summary = &stats.Summary{}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = adminTemplate.Execute(w, map[string]any{
"Enabled": enabled,
"Stats": summary,
"DBPath": s.cfg.DBPath,
"PasswordEnabled": s.cfg.LoginPassword != "",
})
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if !s.validLoginPassword(in.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "登录密码无效"})
return
}
if s.cfg.LoginPassword != "" {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
ssoBaseURL := strings.TrimSpace(r.URL.Query().Get("sso_base_url"))
if ssoBaseURL == "" {
ssoBaseURL = s.cfg.SSOBaseURL
}
if err := validateHTTPBaseURL(ssoBaseURL); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
callback := callbackURL(r)
loginURL := strings.TrimRight(ssoBaseURL, "/") + "/moss/micrologin/#/sso/getauthorizecode" +
"?redirectUri=" + url.QueryEscape(callback) +
"&sourceid=7192038465&moss_sso_account=1"
http.Redirect(w, r, loginURL, http.StatusFound)
}
func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
return
}
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), s.cfg.SSOExchangeURL, code, s.cfg.TokenDecryptKey)
if err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
creds, err := s.credentialsFromProfile(r.Context(), profile)
if err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
if err := s.st.SaveCredentials(creds); err != nil {
s.renderLoginResult(w, false, err.Error())
return
}
s.cfg.Credentials = creds
s.renderLoginResult(w, true, "凭据已保存,可以关闭此页面并使用 OpenAI 兼容接口")
}
// credentialsFromProfile provisions a model API key for the given profile and
// returns full credentials.
func (s *Server) credentialsFromProfile(ctx context.Context, profile auth.Profile) (auth.Credentials, error) {
client, err := s.zhanluClient()
if err != nil {
return auth.Credentials{}, err
}
apiKey, err := client.ProvisionAPIKey(ctx, profile.Email, profile.Organization, profile.Team)
if err != nil {
return auth.Credentials{}, err
}
return auth.Credentials{
APIKey: apiKey,
ModelBaseURL: s.cfg.MobileModelBaseURL,
Email: profile.Email,
Organization: profile.Organization,
Team: profile.Team,
}, nil
}
func (s *Server) renderLoginResult(w http.ResponseWriter, success bool, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = loginResultTemplate.Execute(w, map[string]any{"Success": success, "Message": message})
}
func callbackURL(r *http.Request) string {
host := r.Host
if colon := strings.LastIndex(host, ":"); colon >= 0 {
host = "127.0.0.1" + host[colon:]
} else {
host = "127.0.0.1"
}
return "http://" + host + "/auth/callback"
}
func validateHTTPBaseURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("SSO Base URL 无效:%s", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("SSO Base URL 只支持 http/https%s", raw)
}
return nil
}
func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
if !validChineseMobile(telephone) {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "请输入有效的 11 位手机号"})
return
}
secret := randomSecret16()
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
secretCipher, err := auth.EncryptAuthorization(pub, secret)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.MobileLoginBaseURL, "/") + "/api/query/acepilot-h5/manager/code/getAuthCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "secret": secretCipher}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if out.State != "OK" {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
}
func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Telephone string `json:"telephone"`
Code string `json:"code"`
Secret string `json:"secret"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
telephone := strings.TrimSpace(in.Telephone)
code := strings.TrimSpace(in.Code)
secret := strings.TrimSpace(in.Secret)
if !validChineseMobile(telephone) || len(code) != 6 || len(secret) != 16 {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "手机号、验证码或登录 secret 无效"})
return
}
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.MobileLoginBaseURL, "/") + "/api/query/acepilot-h5/manager/code/checkCode"
var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "code": code}, &out); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if !out.Body.Result {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
return
}
creds, err := decryptPhoneCredentials(out.Body, secret)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
creds.ModelBaseURL = s.cfg.MobileModelBaseURL
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := s.st.SaveCredentials(creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath, "access_key": mask(creds.AccessKey)})
}
// provisionCredentials logs the AK/SK/token into the Zhanlu gateway to obtain
// the user profile, then provisions the model API key used for chat.
func (s *Server) provisionCredentials(ctx context.Context, creds auth.Credentials) (auth.Credentials, error) {
client, err := s.zhanluClient()
if err != nil {
return creds, err
}
profile, err := client.LoginProfile(ctx, creds)
if err != nil {
return creds, err
}
creds.Email = profile.Email
creds.Organization = profile.Organization
creds.Team = profile.Team
apiKey, err := client.ProvisionAPIKey(ctx, profile.Email, profile.Organization, profile.Team)
if err != nil {
return creds, err
}
creds.APIKey = apiKey
return creds, nil
}
func (s *Server) validLoginPassword(password string) bool {
if s.cfg.LoginPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.LoginPassword)) == 1
}
func (s *Server) withLoginSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "请先登录管理页面"})
return
}
next(w, r)
}
}
func (s *Server) hasLoginSession(r *http.Request) bool {
if s.cfg.LoginPassword == "" {
return true
}
c, err := r.Cookie(loginSessionCookieName)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(s.loginSession)) == 1
}
func randomSessionToken() string {
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return randomRequestID()
}
return hex.EncodeToString(b)
}
const loginSessionCookieName = "zhanlu_proxy_session"
type phoneAPIResponse struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
} `json:"body"`
}
func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *phoneAPIResponse) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", s.cfg.PluginVersion)
req.Header.Set("request", randomRequestID())
resp, err := s.upstreamHTTPClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("phone auth upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func decryptPhoneCredentials(body struct {
Result bool `json:"result"`
AK string `json:"ak"`
SK string `json:"sk"`
License string `json:"license"`
}, secret string) (auth.Credentials, error) {
// z4A semantics: try AES-ECB decrypt with secret, fall back to plaintext.
ak := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.AK), secret)
sk := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.SK), secret)
token := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.License), secret)
if ak == "" || sk == "" || token == "" {
return auth.Credentials{}, fmt.Errorf("decrypt phone credentials: missing ak/sk/license")
}
return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil
}
func validChineseMobile(s string) bool {
if len(s) != 11 || s[0] != '1' || s[1] < '3' || s[1] > '9' {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func randomSecret16() string {
const letters = "0123456789abcdef"
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 16)
for i := range b {
b[i] = letters[r.Intn(len(letters))]
}
return string(b)
}
func randomRequestID() string {
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
}
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
c, err := s.st.LoadCredentials()
if err != nil || (c.Validate() != nil && !c.HasAPIKey()) {
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.DBPath})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"path": s.cfg.DBPath,
"access_key": mask(c.AccessKey),
"has_api_key": c.APIKey != "",
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
"email": c.Email,
"saved_at": c.SavedAt,
})
}
func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
var c auth.Credentials
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if c.Validate() == nil && !c.HasAPIKey() {
c.ModelBaseURL = s.cfg.MobileModelBaseURL
provisioned, err := s.provisionCredentials(r.Context(), c)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
c = provisioned
}
if err := s.st.SaveCredentials(c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = c
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
}
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
var in struct {
Code string `json:"code"`
Endpoint string `json:"endpoint"`
DecryptKey string `json:"decrypt_key"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
creds, err := s.credentialsFromProfile(r.Context(), profile)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if in.BaseURL != "" {
creds.ModelBaseURL = in.BaseURL
}
if err := s.st.SaveCredentials(creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
}
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
if s.st == nil {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
q := stats.Query{
Model: r.URL.Query().Get("model"),
Limit: parseLimit(r.URL.Query().Get("limit")),
}
if v := r.URL.Query().Get("since"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
q.Since = t
}
}
if v := r.URL.Query().Get("until"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
q.Until = t
}
}
summary, err := s.st.Stats(q)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": s.statsEnabled, "stats": summary})
}
func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) {
if s.st == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": false})
return
}
if err := s.st.Reset(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// modelTestTimeout caps how long a single model probe may wait for the first
// token. The full upstream timeout (default 300s) is far too long for a probe.
const modelTestTimeout = 30 * time.Second
// getModels returns the live model list advertised by the upstream gateway
// model-info endpoint, fetched on every request so the admin console reflects
// newly published models without a restart. It provisions an API key on demand
// if the stored credentials lack one, mirroring the chat path.
func (s *Server) getModels(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "凭据未配置,请先在凭据登录页登录"})
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
models, err := client.Models(r.Context(), creds.APIKey)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": models})
}
// testModel sends a minimal streaming chat completion to the upstream gateway
// for the requested model and measures the time to first token (TTFT) and the
// total probe duration, so the admin console can report availability and
// latency. The stream is closed as soon as the first content chunk arrives to
// avoid consuming tokens beyond what the probe needs. Probe requests are not
// recorded in token statistics.
func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
var in struct {
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
model := strings.TrimSpace(in.Model)
if model == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "model 不能为空"})
return
}
creds, err := s.currentCredentials()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": "凭据未配置"})
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
return
}
body, _ := json.Marshal(map[string]any{
"model": model,
"messages": []map[string]any{{"role": "user", "content": "hi"}},
"stream": true,
"stream_options": map[string]any{"include_usage": true},
})
ctx, cancel := context.WithTimeout(r.Context(), modelTestTimeout)
defer cancel()
start := time.Now()
resp, err := client.ChatCompletions(ctx, creds.APIKey, body)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error(), "total_ms": time.Since(start).Milliseconds()})
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, string(b)), "total_ms": time.Since(start).Milliseconds()})
return
}
// Read the SSE stream until the first content chunk arrives (or an error),
// then close the connection so the probe consumes at most one token.
reader := bufio.NewReader(resp.Body)
var ttft int64
gotStream := false
available := false
errMsg := ""
for {
line, err := reader.ReadString('\n')
if line != "" {
payload := sseDataPayload(line)
if payload != "" && payload != "[DONE]" {
var evt struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if json.Unmarshal([]byte(payload), &evt) == nil {
if evt.State == "ERROR" {
errMsg = firstNonEmpty(evt.ErrorMessage, "上游返回错误")
break
}
if !gotStream {
gotStream = true
ttft = time.Since(start).Milliseconds()
}
if len(evt.Choices) > 0 && evt.Choices[0].Delta.Content != "" {
available = true
break
}
}
}
}
if err != nil {
if !gotStream && errMsg == "" {
errMsg = err.Error()
}
break
}
}
if gotStream && errMsg == "" {
available = true
}
result := map[string]any{"ok": true, "model": model, "available": available, "total_ms": time.Since(start).Milliseconds()}
if gotStream {
result["ttft_ms"] = ttft
}
if !available {
result["error"] = firstNonEmpty(errMsg, "未收到响应内容")
}
writeJSON(w, http.StatusOK, result)
}
func parseLimit(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
if n > 5000 {
return 5000
}
}
return n
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
modelIDs := []string{}
if creds, err := s.currentCredentials(); err == nil && creds.HasAPIKey() {
if client, cerr := s.zhanluClient(); cerr == nil {
if fetched, merr := client.Models(r.Context(), creds.APIKey); merr == nil && len(fetched) > 0 {
modelIDs = fetched
}
}
}
data := make([]map[string]any, 0, len(modelIDs))
for _, model := range modelIDs {
data = append(data, map[string]any{"id": model, "object": "model", "created": 0, "owned_by": "zhanlu"})
}
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
}
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
var req openai.ChatCompletionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
return
}
if req.Model == "" {
req.Model = "zhanlu/auto"
}
start := time.Now()
clientWantsStream := req.Stream
req.Stream = true
body, err := req.MarshalForUpstream()
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
return
}
if clientWantsStream {
usage, status := s.proxyStream(w, resp)
s.record(req.Model, true, usage, status, start)
return
}
usage, status := s.aggregateStream(w, resp, req.Model)
s.record(req.Model, false, usage, status, start)
}
// record appends a usage observation to the stats store when collection is
// enabled. It never affects the response path; recording errors are ignored.
func (s *Server) record(model string, stream bool, usage any, status string, start time.Time) {
if !s.statsEnabled || s.st == nil {
return
}
_ = s.st.Record(stats.RecordFromUsage(model, stream, usage, status, start))
}
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) (any, string) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
reader := bufio.NewReader(resp.Body)
var usage any
status := "success"
for {
line, err := reader.ReadString('\n')
if line != "" {
if _, werr := io.WriteString(w, line); werr != nil {
// client disconnected mid-stream; stop forwarding
status = "upstream_error"
break
}
if flusher != nil {
flusher.Flush()
}
if payload := sseDataPayload(line); payload != "" && payload != "[DONE]" {
var evt struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Usage any `json:"usage"`
}
if json.Unmarshal([]byte(payload), &evt) == nil {
if evt.State == "ERROR" {
status = "upstream_error"
}
if evt.Usage != nil {
usage = evt.Usage
}
}
}
}
if err != nil {
if err == io.EOF {
break
}
// upstream read error: surface an error event to the client, mirroring
// the previous io.Copy behavior, then mark the request as failed.
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
if flusher != nil {
flusher.Flush()
}
status = "upstream_error"
break
}
}
return usage, status
}
// sseDataPayload returns the payload following a "data:" SSE line, or "" if the
// line is not a data line. Mirrors the parsing in forEachSSEChunk.
func sseDataPayload(line string) string {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "data:") {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
}
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (any, string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
if event.ID != "" {
id = event.ID
}
if event.Usage != nil {
usage = event.Usage
}
if len(event.Choices) > 0 {
content += event.Choices[0].Delta.Content
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
for _, part := range event.Choices[0].Delta.ToolCalls {
call := toolCalls[part.Index]
if call == nil {
call = &toolCall{Type: "function"}
toolCalls[part.Index] = call
}
if part.ID != "" {
call.ID = part.ID
}
if part.Type != "" {
call.Type = part.Type
}
call.Function.Name += part.Function.Name
call.Function.Arguments += part.Function.Arguments
}
if event.Choices[0].FinishReason != nil {
finishReason = *event.Choices[0].FinishReason
}
}
return nil
})
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
return nil, "upstream_error"
}
if id == "" {
id = "chatcmpl-" + randomRequestID()
}
message := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
ordered := make([]*toolCall, 0, len(toolCalls))
for i := 0; i < len(toolCalls); i++ {
if call := toolCalls[i]; call != nil {
ordered = append(ordered, call)
}
}
message["tool_calls"] = ordered
if content == "" {
message["content"] = nil
}
}
if reasoning != "" {
message["reasoning_content"] = reasoning
}
result := map[string]any{
"id": id, "object": "chat.completion", "created": time.Now().Unix(), "model": model,
"choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}},
}
if usage != nil {
result["usage"] = usage
}
writeJSON(w, http.StatusOK, result)
return usage, "success"
}
// forEachSSEChunk feeds each non-empty data: payload to fn, skipping keep-alive
// lines and the [DONE] sentinel. The v1.4.2 gateway streams plain OpenAI SSE.
func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var upstreamError map[string]any
if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
if !json.Valid([]byte(payload)) {
return errors.New("zhanlu stream contained invalid JSON")
}
if err := fn([]byte(payload)); err != nil {
return err
}
}
return scanner.Err()
}
func (s *Server) currentCredentials() (auth.Credentials, error) {
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
return s.cfg.Credentials, nil
}
c, err := s.st.LoadCredentials()
if err != nil {
return auth.Credentials{}, err
}
if c.Validate() != nil && !c.HasAPIKey() {
return auth.Credentials{}, c.Validate()
}
return c, nil
}
func (s *Server) zhanluClient() (*zhanlu.Client, error) {
return s.zhanluClientWithBase(s.cfg.MobileModelBaseURL)
}
// upstreamHTTPClient returns an HTTP/1.1-only client. The Zhanlu gateway drops
// connections that negotiate HTTP/2 (EOF on ALPN handshake).
func (s *Server) upstreamHTTPClient() *http.Client {
return &http.Client{
Timeout: s.cfg.UpstreamTimeout,
Transport: &http.Transport{ForceAttemptHTTP2: false},
}
}
func (s *Server) zhanluClientWithBase(modelBaseURL string) (*zhanlu.Client, error) {
signer, err := s.signer()
if err != nil {
return nil, err
}
return zhanlu.NewClient(s.cfg.MobileLoginBaseURL, modelBaseURL, s.cfg.UpstreamPath, s.cfg.PluginVersion, s.cfg.SM2PrivateKey, signer, s.cfg.UpstreamTimeout), nil
}
func (s *Server) signer() (sign.Signer, error) {
if strings.TrimSpace(s.cfg.PublicKeyPEM) == "" {
return sign.Signer{Encryptor: func(text string) (string, error) {
return "", errors.New("ZHANLU_PUBLIC_KEY_PEM is required for signed upstream requests")
}}, nil
}
pub, err := auth.ParsePublicKey(s.cfg.PublicKeyPEM)
if err != nil {
return sign.Signer{}, err
}
return sign.Signer{PublicKey: pub}, nil
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeOpenAIError(w http.ResponseWriter, code int, message, typ, errCode string) {
writeJSON(w, code, openai.ErrorResponse{Error: openai.ErrorBody{Message: message, Type: typ, Param: nil, Code: errCode}})
}
func mask(s string) string {
if len(s) <= 8 {
return "****"
}
return s[:4] + "****" + s[len(s)-4:]
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// humanNum renders an integer-like value with K/M/B suffixes for compact,
// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 ->
// "1.5M"). Values below 1000 are shown as plain integers. It accepts int,
// int64 and float64 so the same template func works for both Record (int)
// and Totals/ModelStat/DayStat (int64) fields.
func humanNum(v any) string {
var f float64
switch n := v.(type) {
case int:
f = float64(n)
case int64:
f = float64(n)
case float64:
f = n
default:
return fmt.Sprintf("%v", v)
}
switch {
case f < 1000:
return fmt.Sprintf("%d", int64(f))
case f < 1e6:
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e3), ".0") + "K"
case f < 1e9:
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e6), ".0") + "M"
default:
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e9), ".0") + "B"
}
}
func redactSensitive(s string) string {
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
s = redactQueryValue(s, key)
}
return s
}
func redactQueryValue(s, key string) string {
needle := key + "="
for {
start := strings.Index(s, needle)
if start < 0 {
return s
}
valueStart := start + len(needle)
valueEnd := len(s)
if amp := strings.Index(s[valueStart:], "&"); amp >= 0 {
valueEnd = valueStart + amp
}
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
}
}
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理登录</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:40px 36px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:0 0 10px;font-size:24px;font-weight:700;letter-spacing:-.02em}
.lede{margin:0 0 28px;color:var(--body);font-size:14px;line-height:1.6}
.lede code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--ink);background:var(--bg);padding:1px 6px;border-radius:5px;border:1px solid var(--border)}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:11px 18px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;text-decoration:none;color:#fff;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);width:100%}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-primary:disabled{background:#c2c9d4;cursor:not-allowed}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:24px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<main class="card">
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
<h1>湛卢代理登录</h1>
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入管理后台</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
</main>
<script>
const statusEl = document.getElementById('status');
const passwordForm = document.getElementById('password-form');
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) { setStatus('请输入登录密码', 'err'); return; }
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
window.location.href = '/admin';
});
}
</script>
</body>
</html>`))
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢登录结果</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:26px;height:26px}
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
.btn:hover{background:var(--accent-hover)}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
</head>
<body>
<main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/admin">返回管理后台</a>
</main>
</body>
</html>`))
var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
"rate": func(cached, prompt int64) string {
if prompt <= 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
},
"human": humanNum,
}).Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理管理后台</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.btn-ghost:active{transform:translateY(1px)}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
.tab:hover{color:var(--ink)}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.tabpanel{display:none;min-width:0}
.tabpanel.active{display:block;min-width:0}
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
.panel.disabled{background:#eef0f3}
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody tr:last-child td{border-bottom:0}
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.muted{color:var(--muted);font-size:13px;line-height:1.6}
.scroll{overflow-x:auto;min-width:0}
.empty{color:var(--muted);font-size:13px;padding:8px 0}
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.page-btn:disabled{opacity:.4;cursor:not-allowed}
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
.btn-primary.full{width:100%}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
<h1>湛卢代理管理</h1>
</div>
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
</header>
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div>
<section class="tabpanel active" data-tab="stats" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="refresh">刷新</button>
<button class="btn btn-primary" id="reset">重置统计</button>
</div>
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
<div class="panel">
<h2>总览</h2>
<div class="grid4">
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
</div>
</div>
<div class="panel" style="margin-top:20px">
<h2>按模型</h2>
{{if .Stats.PerModel}}
<div class="scroll"><table>
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
<tbody>
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>按日</h2>
{{if .Stats.Daily}}
<div id="daily">
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
</div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>最近请求</h2>
{{if .Stats.Recent}}
<div class="scroll"><table id="recent">
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
<tbody>
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
</tbody>
</table></div>
<div class="pager" id="recent-pager"></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
</section>
<section class="tabpanel" data-tab="models" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
</div>
<div class="panel">
<h2>可用模型</h2>
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
<div class="scroll">
<table id="models-table">
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
</div>
</section>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tabpanel');
function activate(name) {
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.dataset.tab;
activate(name);
if (history.replaceState) history.replaceState(null, '', '#' + name);
});
});
var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models');
else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar');
var max = 1;
for (var i = 0; i < rows.length; i++) {
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
if (t > max) max = t;
}
for (var j = 0; j < rows.length; j++) {
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
}
var refresh = document.getElementById('refresh');
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
var reset = document.getElementById('reset');
if (reset) reset.addEventListener('click', function () {
if (!confirm('确定清空所有统计数据?')) return;
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
});
// paginate the recent-requests table (data is already rendered server-side)
(function () {
var tbody = document.querySelector('#recent tbody');
var pager = document.getElementById('recent-pager');
if (!tbody || !pager) return;
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) { pager.style.display = 'none'; return; }
var pageSize = 10;
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
var page = 1;
function pageList(c, t) {
var p = [];
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
p.push(1);
if (c > 3) p.push('…');
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
for (var m = s; m <= e; m++) p.push(m);
if (c < t - 2) p.push('…');
p.push(t);
return p;
}
function render() {
var start = (page - 1) * pageSize;
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
}
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '></button>';
var list = pageList(page, totalPages);
for (var n = 0; n < list.length; n++) {
var item = list[n];
if (item === '…') html += '<span class="page-btn dots">…</span>';
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
}
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '></button>';
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
pager.innerHTML = html;
}
pager.addEventListener('click', function (ev) {
var btn = ev.target.closest('.page-btn');
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
if (btn.dataset.act === 'prev' && page > 1) page--;
else if (btn.dataset.act === 'next' && page < totalPages) page++;
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
render();
});
render();
})();
var logout = document.getElementById('logout-button');
if (logout) logout.addEventListener('click', async function () {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
})();
(function () {
var statusEl = document.getElementById('status');
var form = document.getElementById('phone-form');
var codeButton = document.getElementById('code-button');
var secret = '';
var countdown = 0;
var countdownTimer = null;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (form) {
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
if (codeButton) codeButton.addEventListener('click', async function () {
var telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
var data = await res.json();
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
if (form) form.addEventListener('submit', async function (event) {
event.preventDefault();
var telephone = form.telephone.value.trim();
var code = form.code.value.trim();
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
setStatus('正在登录并保存凭据...', 'busy');
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
})();
(function () {
var refreshBtn = document.getElementById('models-refresh');
var testAllBtn = document.getElementById('models-test-all');
var statusEl = document.getElementById('models-status');
var tbody = document.querySelector('#models-table tbody');
if (!refreshBtn || !tbody) return;
var loaded = false;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function renderRow(model) {
var tr = document.createElement('tr');
tr.dataset.model = model;
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
'<td class="m-status"><span class="muted">未测试</span></td>' +
'<td class="num m-ttft">—</td>' +
'<td class="num m-total">—</td>' +
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
return tr;
}
async function loadModels() {
setStatus('正在获取模型列表...', 'busy');
refreshBtn.disabled = true;
try {
var res = await fetch('/api/models');
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
var models = data.models || [];
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
tbody.innerHTML = '';
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
} catch (e) {
setStatus('获取模型列表失败:' + e.message, 'err');
} finally {
refreshBtn.disabled = false;
}
}
async function testModel(model, row) {
var statusCell = row.querySelector('.m-status');
var ttftCell = row.querySelector('.m-ttft');
var totalCell = row.querySelector('.m-total');
var btn = row.querySelector('.m-test');
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
ttftCell.textContent = '—';
totalCell.textContent = '—';
btn.disabled = true;
try {
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
var data = await res.json();
if (data.available) {
statusCell.innerHTML = '<span class="badge ok">可用</span>';
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
} else {
statusCell.innerHTML = '<span class="badge err">不可用</span>';
statusCell.title = data.error || '';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
}
} catch (e) {
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
statusCell.title = e.message;
} finally {
btn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadModels);
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
await testModel(rows[i].dataset.model, rows[i]);
}
});
tbody.addEventListener('click', function (ev) {
var btn = ev.target.closest('.m-test');
if (!btn) return;
var row = btn.closest('tr');
if (row && row.dataset.model) testModel(row.dataset.model, row);
});
// lazy-load the model list the first time the tab becomes active
function loadIfActive() {
if (loaded) return;
var panel = document.querySelector('.tabpanel[data-tab="models"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
}
var modelsTab = document.querySelector('.tab[data-tab="models"]');
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
</script>
</body>
</html>`))