Files
zhanlu_proxy/internal/server/server.go
T
m1saka 9aba511abe Refactor server: extract shared helpers, fix tool-call ordering and finish_reason mapping
- Extract callUpstream helper to deduplicate ~30 lines between chatCompletions and responses
- Move HTML templates to internal/server/templates/ via go:embed (server.go 1749→1192 lines)
- Consolidate 4 copies of firstNonEmpty into util.FirstNonEmpty
- Extract chatStreamChunk named type shared by aggregateStream and aggregateResponsesStream
- Fix tool-call ordering: iterate sorted map keys instead of sequential 0..N
- Map upstream finish_reason to Responses API status (length/content_filter → incomplete)
- Surface /v1/models errors as 401/502 instead of silently returning empty 200
- Add Secure cookie flag via isTLSRequest helper
- forEachSSEChunk: use sseDataPayload parser, drop redundant json.Valid, distinguish bufio.ErrTooLong
- Fix StreamIdleTimout typo → StreamIdleTimeout
- sso.go: single Read → io.ReadAll(io.LimitReader), explicit unknown error fallback
2026-08-20 15:46:47 +08:00

1227 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bufio"
"context"
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"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", 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
}
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 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] + "<redacted>" + s[valueEnd:]
}
}