build / build (push) Successful in 2m34s
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder. - Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate. - Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
1493 lines
57 KiB
Go
1493 lines
57 KiB
Go
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/login", s.adminLoginPage)
|
||
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 /admin/stats", s.withLoginSession(s.statsPage))
|
||
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/login", http.StatusFound)
|
||
return
|
||
}
|
||
if s.cfg.LoginPassword == "" {
|
||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
|
||
}
|
||
|
||
func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
|
||
if !s.hasLoginSession(r) {
|
||
http.Redirect(w, r, "/login", http.StatusFound)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
|
||
}
|
||
|
||
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})
|
||
}
|
||
|
||
func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
|
||
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")
|
||
_ = statsTemplate.Execute(w, map[string]any{
|
||
"Enabled": enabled,
|
||
"Stats": summary,
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>湛卢代理登录</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
--bg: #0b1220;
|
||
--panel: rgba(15, 23, 42, .82);
|
||
--line: rgba(255, 255, 255, .12);
|
||
--line-strong: rgba(255, 255, 255, .2);
|
||
--accent-1: #3b82f6;
|
||
--accent-2: #8b5cf6;
|
||
--ink: #f8fafc;
|
||
--body: #b6c2d9;
|
||
--muted: #8ba0b8;
|
||
--ok: #34d399;
|
||
--err: #f87171;
|
||
--radius-lg: 24px;
|
||
--radius-md: 14px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; min-height: 100vh;
|
||
display: grid; place-items: center;
|
||
padding: 24px 16px;
|
||
color: var(--ink);
|
||
background:
|
||
radial-gradient(1.5px 1.5px at 12% 20%, rgba(255,255,255,.22), transparent 55%),
|
||
radial-gradient(1.5px 1.5px at 78% 14%, rgba(255,255,255,.18), transparent 55%),
|
||
radial-gradient(1.5px 1.5px at 88% 68%, rgba(255,255,255,.16), transparent 55%),
|
||
radial-gradient(1.5px 1.5px at 26% 82%, rgba(255,255,255,.14), transparent 55%),
|
||
radial-gradient(1.5px 1.5px at 58% 92%, rgba(255,255,255,.12), transparent 55%),
|
||
linear-gradient(rgba(255,255,255,.022) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(255,255,255,.022) 1px, transparent 1px),
|
||
radial-gradient(60rem 42rem at 12% -8%, rgba(59,130,246,.16), transparent 60%),
|
||
radial-gradient(50rem 36rem at 105% 110%, rgba(139,92,246,.14), transparent 60%),
|
||
linear-gradient(160deg, #0b1220 0%, #111a2e 55%, #0e1626 100%);
|
||
background-size: auto, auto, auto, auto, auto, 44px 44px, 44px 44px, auto, auto, auto;
|
||
animation: fade-in .5s ease both;
|
||
}
|
||
@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||
main {
|
||
width: min(820px, 100%);
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: stretch;
|
||
}
|
||
.panel {
|
||
position: relative;
|
||
border-radius: var(--radius-lg);
|
||
padding: 1px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,.2), rgba(255,255,255,.05) 38%, rgba(255,255,255,.09));
|
||
box-shadow: 0 24px 80px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.08);
|
||
}
|
||
.panel-inner {
|
||
height: 100%;
|
||
border-radius: calc(var(--radius-lg) - 1px);
|
||
background: var(--panel);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
}
|
||
.panel::before {
|
||
content: "";
|
||
position: absolute; inset: 0; border-radius: var(--radius-lg);
|
||
padding: 1px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,.14), transparent 30%);
|
||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
}
|
||
.hero { padding: 34px 30px; display: flex; flex-direction: column; justify-content: space-between; gap: 32px; }
|
||
.eyebrow {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
|
||
color: var(--muted);
|
||
}
|
||
.eyebrow::before {
|
||
content: ""; width: 22px; height: 1.5px;
|
||
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
|
||
}
|
||
h1 {
|
||
margin: 14px 0 0;
|
||
font-size: clamp(30px, 4.4vw, 46px);
|
||
font-weight: 800; letter-spacing: -0.035em; line-height: 1.08;
|
||
background: linear-gradient(92deg, #f8fafc 20%, #bfdbfe 62%, #c4b5fd 100%);
|
||
-webkit-background-clip: text; background-clip: text;
|
||
-webkit-text-fill-color: transparent; color: transparent;
|
||
}
|
||
h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
||
p { margin: 0; color: var(--body); line-height: 1.7; font-size: 15px; }
|
||
.hero p { max-width: 34ch; }
|
||
code {
|
||
font-family: "SF Mono", ui-monospace, "Cascadia Code", Consolas, monospace;
|
||
font-size: 12.5px; color: #bfdbfe; word-break: break-all;
|
||
}
|
||
.cred-path { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: rgba(2,6,23,.5); }
|
||
.cred-path .label { display: block; font-size: 11.5px; letter-spacing: .08em; color: var(--muted); margin-bottom: 6px; }
|
||
.login-card { padding: 30px 28px; display: grid; gap: 16px; align-content: start; }
|
||
.login-card form { display: grid; gap: 18px; }
|
||
.login-card form .btn-primary { width: 100%; margin-top: 2px; }
|
||
.login-card p { font-size: 13.5px; }
|
||
label { display: grid; gap: 8px; font-size: 13.5px; font-weight: 500; color: #cbd5e1; }
|
||
input, textarea {
|
||
width: 100%;
|
||
border: 1px solid rgba(148,163,184,.3);
|
||
border-radius: var(--radius-md);
|
||
padding: 13px 14px;
|
||
background: rgba(2,6,23,.55);
|
||
color: var(--ink);
|
||
outline: none; font: inherit;
|
||
transition: border-color .18s ease, box-shadow .18s ease, background .18s ease;
|
||
}
|
||
input::placeholder { color: #64748b; }
|
||
input:hover { border-color: rgba(148,163,184,.5); }
|
||
input:focus, textarea:focus {
|
||
border-color: var(--accent-1);
|
||
box-shadow: 0 0 0 4px rgba(96,165,250,.16);
|
||
background: rgba(2,6,23,.7);
|
||
}
|
||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.btn {
|
||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||
border: 0; border-radius: var(--radius-md);
|
||
padding: 14px 16px;
|
||
font: inherit; font-weight: 700; font-size: 15px;
|
||
cursor: pointer; text-decoration: none; color: #fff;
|
||
transition: filter .15s ease, transform .06s ease, box-shadow .15s ease, background .15s ease, border-color .15s ease;
|
||
}
|
||
.btn:focus-visible { outline: 2px solid var(--accent-1); outline-offset: 2px; }
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||
box-shadow: 0 10px 24px -10px rgba(99,102,241,.55);
|
||
}
|
||
.btn-primary:hover { filter: brightness(1.1); box-shadow: 0 12px 28px -10px rgba(99,102,241,.7); }
|
||
.btn-primary:active { transform: translateY(1px); }
|
||
.btn-primary:disabled { filter: saturate(.5) brightness(.8); cursor: not-allowed; box-shadow: none; }
|
||
.btn-ghost {
|
||
background: transparent;
|
||
border: 1px solid rgba(148,163,184,.35);
|
||
color: #bfdbfe; font-weight: 600;
|
||
}
|
||
.btn-ghost:hover { border-color: var(--accent-1); color: #dbeafe; background: rgba(96,165,250,.08); }
|
||
.btn-ghost:active { transform: translateY(1px); }
|
||
.btn-ghost:disabled { opacity: .55; cursor: not-allowed; }
|
||
.status {
|
||
min-height: 22px;
|
||
font-size: 13.5px; line-height: 1.6;
|
||
color: var(--muted);
|
||
display: flex; align-items: flex-start; gap: 7px;
|
||
}
|
||
.status::before {
|
||
content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%;
|
||
margin-top: 6px;
|
||
background: currentColor;
|
||
box-shadow: 0 0 0 3px transparent;
|
||
}
|
||
.status[data-state="ok"] { color: var(--ok); }
|
||
.status[data-state="err"] { color: var(--err); }
|
||
.status[data-state="busy"] { color: #93c5fd; }
|
||
.status[data-state="busy"]::before { animation: pulse 1.1s ease-in-out infinite; }
|
||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
|
||
.muted { font-size: 12.5px; color: var(--muted); line-height: 1.7; }
|
||
.muted a, .link { color: #93c5fd; text-decoration: none; }
|
||
.link:hover { text-decoration: underline; }
|
||
.logout { border: 0; background: none; padding: 0; font: inherit; font-size: 12.5px; color: #93c5fd; cursor: pointer; }
|
||
.logout:hover { text-decoration: underline; }
|
||
.spacer { flex: 1; }
|
||
@media (max-width: 760px) {
|
||
body { place-items: start center; padding-top: 20px; }
|
||
main { grid-template-columns: 1fr; gap: 18px; }
|
||
.row { grid-template-columns: 1fr; }
|
||
.hero { padding: 28px 24px; }
|
||
.login-card { padding: 26px 22px; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
body { animation: none; }
|
||
.status[data-state="busy"]::before { animation: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<section class="panel hero">
|
||
<div>
|
||
<div class="eyebrow">Zhanlu Proxy</div>
|
||
<h1>湛卢代理登录</h1>
|
||
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
|
||
</div>
|
||
<div class="cred-path">
|
||
<span class="label">数据库位置</span>
|
||
<code>{{.DBPath}}</code>
|
||
</div>
|
||
</section>
|
||
<section class="panel login-card">
|
||
{{if not .AdminMode}}
|
||
<h2>管理登录</h2>
|
||
<p>请输入服务环境变量 <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>
|
||
{{else}}
|
||
<h2>手机号验证码登录</h2>
|
||
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</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" type="submit">登录并保存凭据</button>
|
||
</form>
|
||
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
|
||
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button class="logout" id="logout-button" type="button">退出管理登录</button>{{end}}</div>
|
||
{{end}}
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const statusEl = document.getElementById('status');
|
||
const form = document.getElementById('phone-form');
|
||
const passwordForm = document.getElementById('password-form');
|
||
const codeButton = document.getElementById('code-button');
|
||
const logoutButton = document.getElementById('logout-button');
|
||
let secret = '';
|
||
let countdown = 0;
|
||
let 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(() => {
|
||
if (countdown <= 0) {
|
||
clearInterval(countdownTimer);
|
||
codeButton.disabled = false;
|
||
codeButton.textContent = '获取验证码';
|
||
return;
|
||
}
|
||
codeButton.textContent = countdown + 's';
|
||
countdown--;
|
||
}, 1000);
|
||
}
|
||
|
||
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/login';
|
||
});
|
||
}
|
||
|
||
if (logoutButton) {
|
||
logoutButton.addEventListener('click', async () => {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/login';
|
||
});
|
||
}
|
||
|
||
if (form) {
|
||
fetch('/api/credentials').then(r => r.json()).then(data => {
|
||
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
||
statusEl.dataset.state = data.configured ? 'ok' : '';
|
||
});
|
||
}
|
||
|
||
codeButton && codeButton.addEventListener('click', async () => {
|
||
const telephone = form.telephone.value.trim();
|
||
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
||
setStatus('请输入有效的 11 位手机号', 'err');
|
||
return;
|
||
}
|
||
codeButton.disabled = true;
|
||
setStatus('正在发送验证码...', 'busy');
|
||
const res = await fetch('/api/auth/code', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ telephone })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.ok) {
|
||
codeButton.disabled = false;
|
||
setStatus(data.error || '验证码发送失败', 'err');
|
||
return;
|
||
}
|
||
secret = data.secret;
|
||
setStatus('验证码已发送', 'ok');
|
||
startCountdown();
|
||
});
|
||
|
||
form && form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const telephone = form.telephone.value.trim();
|
||
const code = form.code.value.trim();
|
||
if (!secret) {
|
||
setStatus('请先获取验证码', 'err');
|
||
return;
|
||
}
|
||
setStatus('正在登录并保存凭据...', 'busy');
|
||
const res = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ telephone, code, secret })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.ok) {
|
||
setStatus(data.error || '登录失败', 'err');
|
||
return;
|
||
}
|
||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
||
});
|
||
</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">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>湛卢登录结果</title><style>
|
||
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--ink:#f8fafc;--body:#b6c2d9;--ok:#34d399;--err:#f87171;--accent-1:#3b82f6;--accent-2:#8b5cf6}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;color:var(--ink);
|
||
background:
|
||
radial-gradient(60rem 42rem at 12% -8%,rgba(59,130,246,.16),transparent 60%),
|
||
radial-gradient(50rem 36rem at 105% 110%,rgba(139,92,246,.14),transparent 60%),
|
||
linear-gradient(160deg,#0b1220 0%,#111a2e 55%,#0e1626 100%);
|
||
animation:fade-in .5s ease both}
|
||
@keyframes fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||
@media(prefers-reduced-motion:reduce){body{animation:none}}
|
||
.card{width:min(480px,100%);padding:44px 36px;border-radius:24px;text-align:center;position:relative;background:rgba(15,23,42,.82);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);box-shadow:0 24px 80px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12)}
|
||
.mark{width:64px;height:64px;margin:0 auto 22px;border-radius:50%;display:grid;place-items:center}
|
||
.mark svg{width:30px;height:30px}
|
||
.mark.ok{background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.35)}
|
||
.mark.err{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.35)}
|
||
h1{margin:0;font-size:24px;font-weight:800;letter-spacing:-.02em}
|
||
p{margin:14px 0 26px;color:var(--body);line-height:1.7;font-size:14.5px;word-break:break-word}
|
||
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:14px;padding:13px 26px;font:inherit;font-weight:700;font-size:14.5px;text-decoration:none;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent-1),var(--accent-2));box-shadow:0 10px 24px -10px rgba(99,102,241,.55);transition:filter .15s ease}
|
||
.btn:hover{filter:brightness(1.1)}
|
||
.btn:focus-visible{outline:2px solid var(--accent-1);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="#34d399" 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="#f87171" 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="/login">返回登录页</a>
|
||
</main></body></html>`))
|
||
|
||
var statsTemplate = template.Must(template.New("stats").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)
|
||
},
|
||
}).Parse(`<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>湛卢 Token 统计</title>
|
||
<style>
|
||
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(255,255,255,.12);--accent-1:#3b82f6;--accent-2:#8b5cf6;--ink:#f8fafc;--body:#b6c2d9;--muted:#8ba0b8;--ok:#34d399;--err:#f87171}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;min-height:100vh;padding:28px 16px 60px;color:var(--ink);
|
||
background:radial-gradient(60rem 42rem at 12% -8%,rgba(59,130,246,.16),transparent 60%),radial-gradient(50rem 36rem at 105% 110%,rgba(139,92,246,.14),transparent 60%),linear-gradient(160deg,#0b1220 0%,#111a2e 55%,#0e1626 100%)}
|
||
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:22px}
|
||
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
|
||
h1{margin:6px 0 0;font-size:clamp(26px,3.4vw,38px);font-weight:800;letter-spacing:-.03em;background:linear-gradient(92deg,#f8fafc 20%,#bfdbfe 62%,#c4b5fd 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
|
||
.actions{display:flex;gap:10px}
|
||
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:12px;padding:11px 18px;font:inherit;font-weight:700;font-size:14px;text-decoration:none;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent-1),var(--accent-2));box-shadow:0 10px 24px -10px rgba(99,102,241,.55);transition:filter .15s ease}
|
||
.btn:hover{filter:brightness(1.1)}
|
||
.btn.ghost{background:transparent;border:1px solid rgba(148,163,184,.35);color:#bfdbfe;box-shadow:none}
|
||
.btn.ghost:hover{border-color:var(--accent-1);background:rgba(96,165,250,.08)}
|
||
.panel{position:relative;border-radius:20px;padding:1px;background:linear-gradient(180deg,rgba(255,255,255,.2),rgba(255,255,255,.05) 38%,rgba(255,255,255,.09));box-shadow:0 24px 80px rgba(0,0,0,.42)}
|
||
.panel-inner{border-radius:19px;background:var(--panel);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);padding:22px 22px}
|
||
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px}
|
||
.stat{border:1px solid var(--line);border-radius:14px;padding:16px 16px;background:rgba(2,6,23,.5)}
|
||
.stat .label{font-size:11.5px;letter-spacing:.06em;color:var(--muted);margin-bottom:8px}
|
||
.stat .val{font-size:26px;font-weight:800;letter-spacing:-.02em}
|
||
.stat .sub{font-size:12px;color:var(--body);margin-top:4px}
|
||
h2{margin:0 0 14px;font-size:17px;font-weight:700;letter-spacing:-.01em}
|
||
table{width:100%;border-collapse:collapse;font-size:13.5px}
|
||
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
|
||
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.05em;text-transform:uppercase}
|
||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11.5px;font-weight:600}
|
||
.badge.ok{background:rgba(52,211,153,.14);color:var(--ok);border:1px solid rgba(52,211,153,.3)}
|
||
.badge.err{background:rgba(248,113,113,.14);color:var(--err);border:1px solid rgba(248,113,113,.3)}
|
||
.badge.stream{background:rgba(96,165,250,.14);color:#93c5fd;border:1px solid rgba(96,165,250,.3)}
|
||
.badge.nonstream{background:rgba(148,163,184,.12);color:var(--muted);border:1px solid rgba(148,163,184,.25)}
|
||
.barrow{display:grid;grid-template-columns:96px 1fr 70px;align-items:center;gap:10px;padding:5px 0}
|
||
.barrow .day{font-size:12.5px;color:var(--body)}
|
||
.barrow .bar{height:10px;border-radius:6px;background:linear-gradient(90deg,var(--accent-1),var(--accent-2));min-width:2px}
|
||
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
||
.muted{color:var(--muted);font-size:13px}
|
||
.scroll{overflow-x:auto}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<div>
|
||
<div class="eyebrow">Zhanlu Proxy · Token 统计</div>
|
||
<h1>Token 消耗统计</h1>
|
||
</div>
|
||
<div class="actions">
|
||
<a class="btn ghost" href="/admin/login">返回登录管理</a>
|
||
<button class="btn" id="refresh">刷新</button>
|
||
<button class="btn ghost" id="reset">重置统计</button>
|
||
</div>
|
||
</header>
|
||
{{if not .Enabled}}<div class="panel"><div class="panel-inner"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div></div>{{end}}
|
||
<div class="panel"><div class="panel-inner">
|
||
<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">{{.Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{.Stats.Totals.CachedTokens}}</div></div>
|
||
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{.Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{.Stats.Totals.ReasoningTokens}}</div></div>
|
||
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{.Stats.Totals.TotalTokens}}</div></div>
|
||
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{.Stats.Totals.CachedTokens}} / Prompt {{.Stats.Totals.PromptTokens}}</div></div>
|
||
</div>
|
||
</div></div>
|
||
<div class="panel"><div class="panel-inner">
|
||
<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">{{.PromptTokens}}</td><td class="num">{{.CompletionTokens}}</td><td class="num">{{.TotalTokens}}</td><td class="num">{{.CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
||
</tbody>
|
||
</table></div>
|
||
{{else}}<p class="muted">暂无数据</p>{{end}}
|
||
</div></div>
|
||
<div class="panel"><div class="panel-inner">
|
||
<h2>按日</h2>
|
||
{{if .Stats.Daily}}
|
||
<div id="daily">
|
||
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="bar" data-token="{{.TotalTokens}}" style="width:0"></div><div class="amt">{{.TotalTokens}}</div></div>{{end}}
|
||
</div>
|
||
{{else}}<p class="muted">暂无数据</p>{{end}}
|
||
</div></div>
|
||
<div class="panel"><div class="panel-inner">
|
||
<h2>最近请求</h2>
|
||
{{if .Stats.Recent}}
|
||
<div class="scroll"><table>
|
||
<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">{{.PromptTokens}}</td><td class="num">{{.CompletionTokens}}</td><td class="num">{{.TotalTokens}}</td><td class="num">{{.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>
|
||
{{else}}<p class="muted">暂无数据</p>{{end}}
|
||
</div></div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
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)) + '%';
|
||
}
|
||
document.getElementById('refresh').addEventListener('click', function () { location.reload(); });
|
||
document.getElementById('reset').addEventListener('click', function () {
|
||
if (!confirm('确定清空所有统计数据?')) return;
|
||
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`))
|