Add token usage stats and consolidate credentials in SQLite
build / build (push) Successful in 2m34s
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.
This commit is contained in:
+304
-32
@@ -21,6 +21,8 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -28,10 +30,12 @@ type Server struct {
|
||||
cfg config.Config
|
||||
mux *http.ServeMux
|
||||
loginSession string
|
||||
st *store.Store
|
||||
statsEnabled bool
|
||||
}
|
||||
|
||||
func New(cfg config.Config) http.Handler {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
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()
|
||||
}
|
||||
@@ -53,6 +57,9 @@ func (s *Server) routes() {
|
||||
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))
|
||||
}
|
||||
@@ -92,7 +99,7 @@ func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
|
||||
_ = 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) {
|
||||
@@ -101,7 +108,7 @@ func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
|
||||
_ = 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) {
|
||||
@@ -159,7 +166,7 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderLoginResult(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
if err := s.st.SaveCredentials(creds); err != nil {
|
||||
s.renderLoginResult(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -304,12 +311,12 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
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.CredentialsPath, "access_key": mask(creds.AccessKey)})
|
||||
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
|
||||
@@ -451,14 +458,14 @@ func randomRequestID() string {
|
||||
}
|
||||
|
||||
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.CredentialsPath})
|
||||
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.CredentialsPath,
|
||||
"path": s.cfg.DBPath,
|
||||
"access_key": mask(c.AccessKey),
|
||||
"has_api_key": c.APIKey != "",
|
||||
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
|
||||
@@ -482,12 +489,12 @@ func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
c = provisioned
|
||||
}
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
|
||||
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.CredentialsPath})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
|
||||
}
|
||||
|
||||
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -516,12 +523,83 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
||||
if in.BaseURL != "" {
|
||||
creds.ModelBaseURL = in.BaseURL
|
||||
}
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
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.CredentialsPath})
|
||||
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) {
|
||||
@@ -554,6 +632,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Model == "" {
|
||||
req.Model = "zhanlu/auto"
|
||||
}
|
||||
start := time.Now()
|
||||
clientWantsStream := req.Stream
|
||||
req.Stream = true
|
||||
body, err := req.MarshalForUpstream()
|
||||
@@ -569,7 +648,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.cfg.Credentials = creds
|
||||
_ = auth.SaveCredentials(s.cfg.CredentialsPath, creds)
|
||||
_ = s.st.SaveCredentials(creds)
|
||||
}
|
||||
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||
@@ -584,6 +663,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
@@ -594,33 +674,93 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
s.proxyStream(w, resp)
|
||||
usage, status := s.proxyStream(w, resp)
|
||||
s.record(req.Model, true, usage, status, start)
|
||||
return
|
||||
}
|
||||
s.aggregateStream(w, resp, req.Model)
|
||||
usage, status := s.aggregateStream(w, resp, req.Model)
|
||||
s.record(req.Model, false, usage, status, start)
|
||||
}
|
||||
|
||||
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) {
|
||||
// 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)
|
||||
_, err := io.Copy(w, resp.Body)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) {
|
||||
// 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"
|
||||
@@ -690,7 +830,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
||||
})
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
|
||||
return
|
||||
return nil, "upstream_error"
|
||||
}
|
||||
if id == "" {
|
||||
id = "chatcmpl-" + randomRequestID()
|
||||
@@ -719,6 +859,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
||||
result["usage"] = usage
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return usage, "success"
|
||||
}
|
||||
|
||||
// forEachSSEChunk feeds each non-empty data: payload to fn, skipping keep-alive
|
||||
@@ -753,7 +894,7 @@ func (s *Server) currentCredentials() (auth.Credentials, error) {
|
||||
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
|
||||
return s.cfg.Credentials, nil
|
||||
}
|
||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||||
c, err := s.st.LoadCredentials()
|
||||
if err != nil {
|
||||
return auth.Credentials{}, err
|
||||
}
|
||||
@@ -1037,8 +1178,8 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
||||
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
|
||||
</div>
|
||||
<div class="cred-path">
|
||||
<span class="label">凭据保存位置</span>
|
||||
<code>{{.CredentialsPath}}</code>
|
||||
<span class="label">数据库位置</span>
|
||||
<code>{{.DBPath}}</code>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel login-card">
|
||||
@@ -1179,7 +1320,7 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
||||
setStatus(data.error || '登录失败', 'err');
|
||||
return;
|
||||
}
|
||||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';JSON:' + (data.path || ''), 'ok');
|
||||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -1218,3 +1359,134 @@ p{margin:14px 0 26px;color:var(--body);line-height:1.7;font-size:14.5px;word-bre
|
||||
<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>`))
|
||||
|
||||
Reference in New Issue
Block a user