package server import ( "bufio" "context" crand "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math/rand" "net/http" "net/url" "sort" "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/util" "git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu" ) type Server struct { cfg config.Config mux *http.ServeMux loginSession string st *store.Store statsEnabled bool } func New(cfg config.Config, st *store.Store) http.Handler { s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled} if cfg.LoginPassword != "" { s.loginSession = randomSessionToken() } s.routes() return s.mux } func (s *Server) routes() { s.mux.HandleFunc("GET /", s.index) s.mux.HandleFunc("GET /healthz", s.healthz) s.mux.HandleFunc("GET /login", s.loginPage) s.mux.HandleFunc("GET /admin", s.adminPage) s.mux.HandleFunc("GET /admin/login", s.redirectAdmin) s.mux.HandleFunc("GET /admin/stats", s.redirectAdmin) s.mux.HandleFunc("POST /api/login", s.passwordLogin) s.mux.HandleFunc("POST /api/logout", s.passwordLogout) s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO)) s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback)) s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode)) s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode)) s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials)) s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials)) s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode)) s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats)) s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats)) s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels)) s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel)) s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses)) } func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) index(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } http.Redirect(w, r, "/login", http.StatusFound) } func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.cfg.OpenAIAPIKey != "" { got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if got != s.cfg.OpenAIAPIKey { writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key") return } } next(w, r) } } func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { if s.hasLoginSession(r) { http.Redirect(w, r, "/admin", http.StatusFound) return } if s.cfg.LoginPassword == "" { http.Redirect(w, r, "/admin", http.StatusFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "PasswordEnabled": true}) } func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin", http.StatusFound) } // adminPage renders the unified management console: a single page with tabs for // token statistics (default) and credential (phone code) login. It requires an // authenticated management session; without one it bounces to /login. func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { if !s.hasLoginSession(r) { http.Redirect(w, r, "/login", http.StatusFound) return } var summary *stats.Summary enabled := s.statsEnabled if s.st != nil { if sm, err := s.st.Stats(stats.Query{}); err == nil { summary = sm } } if summary == nil { summary = &stats.Summary{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = adminTemplate.Execute(w, map[string]any{ "Enabled": enabled, "Stats": summary, "DBPath": s.cfg.DBPath, "PasswordEnabled": s.cfg.LoginPassword != "", }) } // isTLSRequest reports whether the request arrived over TLS, either directly // (r.TLS != nil) or behind a reverse proxy that set X-Forwarded-Proto: https. // The session cookie is only marked Secure over TLS so it still works on the // default http://127.0.0.1 loopback deployment. func isTLSRequest(r *http.Request) bool { if r.TLS != nil { return true } return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } 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, Secure: isTLSRequest(r), 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, Secure: isTLSRequest(r), 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": util.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": util.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", util.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": util.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 := util.FirstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL) decryptKey := util.FirstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey) profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } creds, err := s.credentialsFromProfile(r.Context(), profile) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } if in.BaseURL != "" { creds.ModelBaseURL = in.BaseURL } if err := s.st.SaveCredentials(creds); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } s.cfg.Credentials = creds writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath}) } func (s *Server) getStats(w http.ResponseWriter, r *http.Request) { if s.st == nil { writeJSON(w, http.StatusOK, map[string]any{"enabled": false}) return } q := stats.Query{ Model: r.URL.Query().Get("model"), Limit: parseLimit(r.URL.Query().Get("limit")), } if v := r.URL.Query().Get("since"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { q.Since = t } } if v := r.URL.Query().Get("until"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { q.Until = t } } summary, err := s.st.Stats(q) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"enabled": s.statsEnabled, "stats": summary}) } func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) { if s.st == nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": false}) return } if err := s.st.Reset(); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // modelTestTimeout caps how long a single model probe may wait for the first // token. The full upstream timeout (default 300s) is far too long for a probe. const modelTestTimeout = 30 * time.Second // getModels returns the live model list advertised by the upstream gateway // model-info endpoint, fetched on every request so the admin console reflects // newly published models without a restart. It provisions an API key on demand // if the stored credentials lack one, mirroring the chat path. func (s *Server) getModels(w http.ResponseWriter, r *http.Request) { creds, err := s.currentCredentials() if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "凭据未配置,请先在凭据登录页登录"}) return } if !creds.HasAPIKey() { creds, err = s.provisionCredentials(r.Context(), creds) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()}) return } s.cfg.Credentials = creds _ = s.st.SaveCredentials(creds) } modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL) client, err := s.zhanluClientWithBase(modelBaseURL) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) return } models, err := client.Models(r.Context(), creds.APIKey) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": models}) } // testModel sends a minimal streaming chat completion to the upstream gateway // for the requested model and measures the time to first token (TTFT) and the // total probe duration, so the admin console can report availability and // latency. The stream is closed as soon as the first content chunk arrives to // avoid consuming tokens beyond what the probe needs. Probe requests are not // recorded in token statistics. func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { var in struct { Model string `json:"model"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } model := strings.TrimSpace(in.Model) if model == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "model 不能为空"}) return } creds, err := s.currentCredentials() if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": "凭据未配置"}) return } if !creds.HasAPIKey() { creds, err = s.provisionCredentials(r.Context(), creds) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()}) return } s.cfg.Credentials = creds _ = s.st.SaveCredentials(creds) } modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL) client, err := s.zhanluClientWithBase(modelBaseURL) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()}) return } body, _ := json.Marshal(map[string]any{ "model": model, "messages": []map[string]any{{"role": "user", "content": "hi"}}, "stream": true, "stream_options": map[string]any{"include_usage": true}, }) ctx, cancel := context.WithTimeout(r.Context(), modelTestTimeout) defer cancel() start := time.Now() resp, err := client.ChatCompletions(ctx, creds.APIKey, body) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error(), "total_ms": time.Since(start).Milliseconds()}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, string(b)), "total_ms": time.Since(start).Milliseconds()}) return } // Read the SSE stream until the first content chunk arrives (or an error), // then close the connection so the probe consumes at most one token. reader := bufio.NewReader(resp.Body) var ttft int64 gotStream := false available := false errMsg := "" for { line, err := reader.ReadString('\n') if line != "" { payload := sseDataPayload(line) if payload != "" && payload != "[DONE]" { var evt struct { State string `json:"state"` ErrorMessage string `json:"errorMessage"` Choices []struct { Delta struct { Content string `json:"content"` } `json:"delta"` } `json:"choices"` } if json.Unmarshal([]byte(payload), &evt) == nil { if evt.State == "ERROR" { errMsg = util.FirstNonEmpty(evt.ErrorMessage, "上游返回错误") break } if !gotStream { gotStream = true ttft = time.Since(start).Milliseconds() } if len(evt.Choices) > 0 && evt.Choices[0].Delta.Content != "" { available = true break } } } } if err != nil { if !gotStream && errMsg == "" { errMsg = err.Error() } break } } if gotStream && errMsg == "" { available = true } result := map[string]any{"ok": true, "model": model, "available": available, "total_ms": time.Since(start).Milliseconds()} if gotStream { result["ttft_ms"] = ttft } if !available { result["error"] = util.FirstNonEmpty(errMsg, "未收到响应内容") } writeJSON(w, http.StatusOK, result) } func parseLimit(s string) int { n := 0 for _, c := range s { if c < '0' || c > '9' { return 0 } n = n*10 + int(c-'0') if n > 5000 { return 5000 } } return n } func (s *Server) models(w http.ResponseWriter, r *http.Request) { creds, err := s.currentCredentials() if err != nil { writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials") 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 := util.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 } modelIDs, err := client.Models(r.Context(), creds.APIKey) if err != nil { msg := "zhanlu model list failed" if s.cfg.Debug { msg = redactSensitive(err.Error()) } writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_models_failed") return } 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}) } // callUpstream performs the shared upstream request sequence for // /v1/chat/completions and /v1/responses: provision an API key on demand when // the resolved credentials lack one, build the HTTP/1.1 zhanlu client, POST // the marshalled chat-completions body to the gateway, and handle upstream // errors uniformly (OpenAI error + stats record). creds is the credential set // already resolved by the caller. On success it returns the upstream response // (caller closes Body); on failure it writes the error and records the failed // request, returning ok=false. func (s *Server) callUpstream(w http.ResponseWriter, r *http.Request, creds auth.Credentials, model string, body []byte, clientWantsStream bool, start time.Time) (resp *http.Response, ok bool) { if !creds.HasAPIKey() { var err error 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 nil, false } s.cfg.Credentials = creds _ = s.st.SaveCredentials(creds) } modelBaseURL := util.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 nil, false } 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(model, clientWantsStream, nil, "upstream_error", start) return nil, false } if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() 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(model, clientWantsStream, nil, "upstream_error", start) return nil, false } return resp, true } 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 } resp, ok := s.callUpstream(w, r, creds, req.Model, body, clientWantsStream, start) if !ok { return } defer resp.Body.Close() 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:")) } // chatStreamChunk is one SSE data event from the upstream chat-completions // stream. It is shared by the non-streaming aggregation paths of // /v1/chat/completions (aggregateStream) and /v1/responses // (aggregateResponsesStream), which previously redeclared this anonymous // struct inline in each function. type chatStreamChunk 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"` } 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 chatStreamChunk 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 { indices := make([]int, 0, len(toolCalls)) for i := range toolCalls { indices = append(indices, i) } sort.Ints(indices) ordered := make([]*toolCall, 0, len(toolCalls)) for _, i := range indices { ordered = append(ordered, toolCalls[i]) } 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() { payload := sseDataPayload(scanner.Text()) if payload == "" || payload == "[DONE]" { continue } if err := fn([]byte(payload)); err != nil { return err } } err := scanner.Err() if err != nil && errors.Is(err, bufio.ErrTooLong) { return errors.New("zhanlu stream chunk exceeded 2MB limit") } return 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:] } // humanNum renders an integer-like value with K/M/B suffixes for compact, // scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 -> // "1.5M"). Values below 1000 are shown as plain integers. It accepts int, // int64 and float64 so the same template func works for both Record (int) // and Totals/ModelStat/DayStat (int64) fields. func humanNum(v any) string { var f float64 switch n := v.(type) { case int: f = float64(n) case int64: f = float64(n) case float64: f = n default: return fmt.Sprintf("%v", v) } switch { case f < 1000: return fmt.Sprintf("%d", int64(f)) case f < 1e6: return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e3), ".0") + "K" case f < 1e9: return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e6), ".0") + "M" default: return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e9), ".0") + "B" } } func redactSensitive(s string) string { for _, key := range []string{"AccessKey", "authorization", "Signature"} { s = redactQueryValue(s, key) } return s } func redactQueryValue(s, key string) string { needle := key + "=" for { start := strings.Index(s, needle) if start < 0 { return s } valueStart := start + len(needle) valueEnd := len(s) if amp := strings.Index(s[valueStart:], "&"); amp >= 0 { valueEnd = valueStart + amp } s = s[:valueStart] + "" + s[valueEnd:] } }