build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory ring buffer. Each entry captures request headers (Authorization masked), request body, response status, and response body — all up to 1 MB. - recorder.go: RecentRecorder ring buffer, recordingResponseWriter, withRecording middleware, getRecent handler - server.go: add recorder to Server, wrap /v1/ routes, add /api/recent - admin.html: new 最近请求 tab with lazy-load and expandable cards - recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
1225 lines
40 KiB
Go
1225 lines
40 KiB
Go
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
|
||
recorder *RecentRecorder
|
||
}
|
||
|
||
func New(cfg config.Config, st *store.Store) http.Handler {
|
||
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()}
|
||
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/recent", s.withLoginSession(s.getRecent))
|
||
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.withRecording(s.withAPIKey(s.models)))
|
||
s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
|
||
s.mux.HandleFunc("POST /v1/responses", s.withRecording(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] + "<redacted>" + s[valueEnd:]
|
||
}
|
||
}
|