diff --git a/.gitignore b/.gitignore index a2af2f8..e9b4404 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ -credentials.json +zhanlu.db +zhanlu.db-shm +zhanlu.db-wal +*.db +*.db-shm +*.db-wal extension/ *.exe *.log diff --git a/README.md b/README.md index 2edad4e..e7fd84b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。 - 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。 - OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。 +- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。 ## 运行 @@ -37,17 +38,17 @@ http://127.0.0.1:8080/ /opt/zhanlu-proxy/zhanlu-proxy ``` -凭据文件放在: +数据库放在: ```text -/opt/zhanlu-proxy/credentials.json +/opt/zhanlu-proxy/zhanlu.db ``` 创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env`: ```env ZHANLU_LISTEN_ADDR=:8080 -ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json +ZHANLU_DB_FILE=/opt/zhanlu-proxy/zhanlu.db ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway ZHANLU_UPSTREAM_TIMEOUT=300s @@ -106,20 +107,22 @@ journalctl -u zhanlu-proxy -f - 使用本次 `secret` AES 解密响应中的 `ak`、`sk`、`license`,得到 `AccessKey`、`SecretKey`、`Token`。 - 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`(RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)。 - 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`。 -- 凭据(含 `apiKey`、`modelBaseUrl`、email 等)会写入 JSON 文件,后续 OpenAI 兼容接口自动使用。 +- 凭据(含 `apiKey`、`modelBaseUrl`、email 等)会写入本地 SQLite 数据库(`zhanlu.db`),后续 OpenAI 兼容接口自动使用。 -默认保存到当前执行目录: +默认数据库保存在当前执行目录: ```text -credentials.json +zhanlu.db ``` 可以通过环境变量覆盖: ```powershell -$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json" +$env:ZHANLU_DB_FILE="E:\path\to\zhanlu.db" ``` +凭据仅持久化在数据库中,不再使用 JSON 文件。 + 手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`): ```powershell @@ -184,7 +187,8 @@ curl http://127.0.0.1:8080/v1/models ` | `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL`) | | `ZHANLU_MOBILE_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL | | `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 | -| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 | +| `ZHANLU_DB_FILE` | `zhanlu.db` | 本地 SQLite 数据库路径,凭据与 token 统计均存于此,默认当前执行目录 | +| `ZHANLU_STATS_DISABLED` | `false` | 设为 `true` 关闭 token 用量记录(仅停止写入统计,凭据存储不受影响) | | `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey | | `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey | | `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token | @@ -207,14 +211,33 @@ curl http://127.0.0.1:8080/v1/models ` 服务启动时按以下优先级加载凭据: 1. 环境变量 `ZHANLU_ACCESS_KEY`、`ZHANLU_SECRET_KEY`、`ZHANLU_TOKEN` 或 `ZHANLU_API_KEY`。 -2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件。 +2. `ZHANLU_DB_FILE` 数据库中持久化的凭据行。 -登录页面保存后,运行中的服务会立即使用新凭据。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写凭据文件。 +登录页面保存后,运行中的服务会立即使用新凭据并写入数据库。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写数据库。 + +## Token 消耗统计 + +代理在 `/v1/chat/completions` 完成后解析上游 `usage`(流式路径在透传 SSE 的同时旁路解析末块 `usage`,非流式路径在聚合时解析),将以下维度写入 `ZHANLU_DB_FILE`: + +- 时间、模型、流式/非流式 +- `prompt_tokens` / `completion_tokens` / `total_tokens` / `reasoning_tokens`(思考模型) +- `cached_tokens`(来自 `prompt_tokens_details.cached_tokens`,提示缓存命中的 token 数)与缓存命中率 +- 请求状态(`success` / `upstream_error`)与耗时 + +缓存命中率为 `cached_tokens / prompt_tokens`,仅在模型/网关支持 prompt 缓存且上游返回 `cached_tokens` 时非零。 + +管理页(需先登录管理页面)提供: + +- `GET /admin/stats`:可视化页面,展示总览、按模型、按日柱状、最近请求明细,带重置按钮。 +- `GET /api/stats`:JSON 接口,支持 `since`/`until`(RFC3339)、`model`、`limit` 查询参数。 +- `POST /api/stats/reset`:清空统计(凭据不受影响)。 + +设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。 ## 安全说明 -- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。 -- 默认保存在当前执行目录的 `credentials.json`。 +- `zhanlu.db` 数据库包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。 +- 默认保存在当前执行目录的 `zhanlu.db`(建议通过 `ZHANLU_DB_FILE` 指向受保护路径)。 - 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。 - 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。 - `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。 diff --git a/cmd/zhanlu-proxy/main.go b/cmd/zhanlu-proxy/main.go index 7ab502e..0d0099a 100644 --- a/cmd/zhanlu-proxy/main.go +++ b/cmd/zhanlu-proxy/main.go @@ -6,6 +6,7 @@ import ( "git.misaka.ren/M1saka/zhanlu_proxy/internal/config" "git.misaka.ren/M1saka/zhanlu_proxy/internal/server" + "git.misaka.ren/M1saka/zhanlu_proxy/internal/store" ) func main() { @@ -14,7 +15,20 @@ func main() { log.Fatal(err) } - h := server.New(cfg) + st, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer st.Close() + + // Credential precedence at startup: environment variables > db row. + if cfg.Credentials.Validate() != nil && !cfg.Credentials.HasAPIKey() { + if dbCreds, derr := st.LoadCredentials(); derr == nil && (dbCreds.HasAPIKey() || dbCreds.Validate() == nil) { + cfg.Credentials = dbCreds + } + } + + h := server.New(cfg, st) log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr) log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr) if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil { diff --git a/go.mod b/go.mod index 8ce4ba6..75e68e7 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,16 @@ go 1.25.0 require github.com/emmansun/gmsm v0.44.1 -require golang.org/x/crypto v0.54.0 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.56.0 // indirect +) diff --git a/go.sum b/go.sum index f7e4238..458584b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,24 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emmansun/gmsm v0.44.1 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4= github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 61196b6..ffae077 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -1,10 +1,7 @@ package auth import ( - "encoding/json" "errors" - "os" - "path/filepath" "strings" "time" ) @@ -49,30 +46,3 @@ func (c Credentials) Validate() error { func (c Credentials) HasAPIKey() bool { return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" } - -func LoadCredentials(path string) (Credentials, error) { - b, err := os.ReadFile(path) - if err != nil { - return Credentials{}, err - } - var c Credentials - if err := json.Unmarshal(b, &c); err != nil { - return Credentials{}, err - } - return c, c.Validate() -} - -func SaveCredentials(path string, c Credentials) error { - if err := c.Validate(); err != nil { - return err - } - c.SavedAt = time.Now() - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - return os.WriteFile(path, b, 0o600) -} diff --git a/internal/config/config.go b/internal/config/config.go index e85bc82..728b57b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "os" - "path/filepath" "strings" "time" @@ -14,7 +13,8 @@ type Config struct { MobileLoginBaseURL string MobileModelBaseURL string UpstreamPath string - CredentialsPath string + DBPath string + StatsDisabled bool SSOExchangeURL string SSOBaseURL string TokenDecryptKey string @@ -36,7 +36,8 @@ func Load() (Config, error) { MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")), MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"), UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"), - CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()), + DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"), + StatsDisabled: strings.EqualFold(os.Getenv("ZHANLU_STATS_DISABLED"), "true"), SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"), SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"), TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"), @@ -56,12 +57,8 @@ func Load() (Config, error) { Token: os.Getenv("ZHANLU_TOKEN"), APIKey: os.Getenv("ZHANLU_API_KEY"), } - if cfg.Credentials.Validate() == nil || cfg.Credentials.HasAPIKey() { - return cfg, nil - } - if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil { - cfg.Credentials = creds - } + // Credentials from the environment were incomplete; the store serves the + // persisted row at runtime. return cfg, nil } @@ -93,10 +90,6 @@ func firstNonEmpty(values ...string) string { return "" } -func defaultCredentialsPath() string { - return filepath.Join(".", "credentials.json") -} - const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB -----END PUBLIC KEY-----` diff --git a/internal/server/server.go b/internal/server/server.go index 182c26e..721d21c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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(`
输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。
{{.CredentialsPath}}
+ 数据库位置
+ {{.DBPath}}