From 9aba511abef72ec8da5dc953d09d0b24989b72fb Mon Sep 17 00:00:00 2001 From: m1saka Date: Thu, 20 Aug 2026 15:46:47 +0800 Subject: [PATCH] Refactor server: extract shared helpers, fix tool-call ordering and finish_reason mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/auth/sso.go | 27 +- internal/config/config.go | 16 +- internal/server/responses.go | 94 +-- internal/server/server.go | 809 ++++---------------- internal/server/templates.go | 49 ++ internal/server/templates/admin.html | 439 +++++++++++ internal/server/templates/login.html | 81 ++ internal/server/templates/login_result.html | 41 + internal/util/util.go | 17 + internal/zhanlu/client.go | 16 +- 10 files changed, 824 insertions(+), 765 deletions(-) create mode 100644 internal/server/templates.go create mode 100644 internal/server/templates/admin.html create mode 100644 internal/server/templates/login.html create mode 100644 internal/server/templates/login_result.html create mode 100644 internal/util/util.go diff --git a/internal/auth/sso.go b/internal/auth/sso.go index 8dfeb92..7d7e149 100644 --- a/internal/auth/sso.go +++ b/internal/auth/sso.go @@ -5,9 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strings" "time" + + "git.misaka.ren/M1saka/zhanlu_proxy/internal/util" ) // ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu @@ -39,9 +42,8 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b := make([]byte, 1024) - n, _ := resp.Body.Read(b) - return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n]))) + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } var exchange ExchangeResponse @@ -49,11 +51,17 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey return Profile{}, err } if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" { - msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode) + msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode) + if msg == "" { + msg = "unknown error" + } return Profile{}, fmt.Errorf("exchange failed: %s", msg) } if exchange.State != "" && exchange.State != "OK" { - msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State) + msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State) + if msg == "" { + msg = "unknown error" + } return Profile{}, fmt.Errorf("exchange failed: %s", msg) } @@ -95,12 +103,3 @@ type ExchangeResponse struct { Result map[string]any `json:"result"` Data map[string]any `json:"data"` } - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "unknown error" -} diff --git a/internal/config/config.go b/internal/config/config.go index 728b57b..98cf178 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "time" "git.misaka.ren/M1saka/zhanlu_proxy/internal/auth" + "git.misaka.ren/M1saka/zhanlu_proxy/internal/util" ) type Config struct { @@ -25,7 +26,7 @@ type Config struct { LoginPassword string PluginVersion string UpstreamTimeout time.Duration - StreamIdleTimout time.Duration + StreamIdleTimeout time.Duration Debug bool Credentials auth.Credentials } @@ -33,7 +34,7 @@ type Config struct { func Load() (Config, error) { cfg := Config{ ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"), - MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")), + MobileLoginBaseURL: util.FirstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")), MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"), UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"), DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"), @@ -48,7 +49,7 @@ func Load() (Config, error) { LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"), PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"), UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second), - StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second), + StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second), Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"), } cfg.Credentials = auth.Credentials{ @@ -81,15 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration { return d } -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB -----END PUBLIC KEY-----` diff --git a/internal/server/responses.go b/internal/server/responses.go index 284737b..c0547bd 100644 --- a/internal/server/responses.go +++ b/internal/server/responses.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "sort" "time" "git.misaka.ren/M1saka/zhanlu_proxy/internal/openai" @@ -46,42 +47,11 @@ func (s *Server) responses(w http.ResponseWriter, r *http.Request) { 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 := 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 - } - resp, err := client.ChatCompletions(r.Context(), creds.APIKey, upstreamBody) - 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(chatReq.Model, clientWantsStream, nil, "upstream_error", start) + resp, ok := s.callUpstream(w, r, creds, chatReq.Model, upstreamBody, clientWantsStream, start) + if !ok { return } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - 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(chatReq.Model, clientWantsStream, nil, "upstream_error", start) - return - } if clientWantsStream { usage, status := s.proxyResponsesStream(w, resp, chatReq.Model, meta) @@ -110,27 +80,7 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp } toolCalls := map[int]*toolCall{} err := forEachSSEChunk(resp.Body, func(chunk []byte) error { - var event 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"` - } + var event chatStreamChunk if err := json.Unmarshal(chunk, &event); err != nil { return err } @@ -170,7 +120,14 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp } _ = id - _ = finishReason + // Map the upstream finish_reason onto the Responses API status: a + // length/content_filter cutoff means the response is incomplete, not + // completed. Default ("stop"/"tool_calls") stays "completed". + status := "completed" + switch finishReason { + case "length", "content_filter": + status = "incomplete" + } respID := "resp_" + randomRequestID() output := []any{} @@ -195,11 +152,14 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp } 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 := 0; i < len(toolCalls); i++ { - if call := toolCalls[i]; call != nil { - ordered = append(ordered, call) - } + for _, i := range indices { + ordered = append(ordered, toolCalls[i]) } for _, tc := range ordered { output = append(output, map[string]any{ @@ -221,7 +181,7 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp result := map[string]any{ "id": respID, "object": "response", "created_at": time.Now().Unix(), - "model": model, "status": "completed", "output": output, + "model": model, "status": status, "output": output, "parallel_tool_calls": meta.ParallelToolCalls, "tool_choice": meta.ToolChoice, "tools": meta.Tools, @@ -254,6 +214,7 @@ type responsesStreamState struct { fullReasoning string usage any status string + finishReason string // tool call tracking tools map[int]*streamToolCall @@ -504,9 +465,16 @@ func (st *responsesStreamState) finish(model string) { }) } + // Map the upstream finish_reason onto the Responses API status, mirroring + // the non-streaming path: a length/content_filter cutoff is "incomplete". + respStatus := "completed" + switch st.finishReason { + case "length", "content_filter": + respStatus = "incomplete" + } completedResp := map[string]any{ "id": st.respID, "object": "response", "created_at": time.Now().Unix(), - "model": model, "status": "completed", "output": finalOutput, + "model": model, "status": respStatus, "output": finalOutput, "parallel_tool_calls": st.meta.ParallelToolCalls, "tool_choice": st.meta.ToolChoice, "tools": st.meta.Tools, @@ -546,6 +514,7 @@ func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response } `json:"function"` } `json:"tool_calls"` } `json:"delta"` + FinishReason *string `json:"finish_reason"` } `json:"choices"` } if jErr := json.Unmarshal([]byte(payload), &evt); jErr == nil { @@ -557,6 +526,9 @@ func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response } if len(evt.Choices) > 0 { delta := evt.Choices[0].Delta + if evt.Choices[0].FinishReason != nil { + st.finishReason = *evt.Choices[0].FinishReason + } reasoningDelta := delta.ReasoningContent + delta.Reasoning if reasoningDelta != "" { st.handleReasoning(reasoningDelta) diff --git a/internal/server/server.go b/internal/server/server.go index c7a4195..a8b3114 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,11 +9,11 @@ import ( "encoding/json" "errors" "fmt" - "html/template" "io" "math/rand" "net/http" "net/url" + "sort" "strings" "time" @@ -23,6 +23,7 @@ import ( "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" ) @@ -137,6 +138,17 @@ func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { }) } +// 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"` @@ -150,13 +162,13 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) { return } if s.cfg.LoginPassword != "" { - http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())}) + 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, SameSite: http.SameSiteLaxMode, MaxAge: -1}) + 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}) } @@ -283,7 +295,7 @@ func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) { return } if out.State != "OK" { - writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")}) + 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}) @@ -323,7 +335,7 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) { return } if !out.Body.Result { - writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")}) + writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": util.FirstNonEmpty(out.ErrorMessage, "验证码校验失败")}) return } creds, err := decryptPhoneCredentials(out.Body, secret) @@ -494,7 +506,7 @@ func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) { "path": s.cfg.DBPath, "access_key": mask(c.AccessKey), "has_api_key": c.APIKey != "", - "model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL), + "model_base": util.FirstNonEmpty(c.ModelBaseURL, c.BaseURL), "email": c.Email, "saved_at": c.SavedAt, }) @@ -534,8 +546,8 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) return } - endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL) - decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey) + 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()}) @@ -619,7 +631,7 @@ func (s *Server) getModels(w http.ResponseWriter, r *http.Request) { s.cfg.Credentials = creds _ = s.st.SaveCredentials(creds) } - modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL) + 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()}) @@ -666,7 +678,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { s.cfg.Credentials = creds _ = s.st.SaveCredentials(creds) } - modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL) + 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()}) @@ -715,7 +727,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { } if json.Unmarshal([]byte(payload), &evt) == nil { if evt.State == "ERROR" { - errMsg = firstNonEmpty(evt.ErrorMessage, "上游返回错误") + errMsg = util.FirstNonEmpty(evt.ErrorMessage, "上游返回错误") break } if !gotStream { @@ -744,7 +756,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) { result["ttft_ms"] = ttft } if !available { - result["error"] = firstNonEmpty(errMsg, "未收到响应内容") + result["error"] = util.FirstNonEmpty(errMsg, "未收到响应内容") } writeJSON(w, http.StatusOK, result) } @@ -764,13 +776,34 @@ func parseLimit(s string) int { } func (s *Server) models(w http.ResponseWriter, r *http.Request) { - modelIDs := []string{} - if creds, err := s.currentCredentials(); err == nil && creds.HasAPIKey() { - if client, cerr := s.zhanluClient(); cerr == nil { - if fetched, merr := client.Models(r.Context(), creds.APIKey); merr == nil && len(fetched) > 0 { - modelIDs = fetched - } + 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 { @@ -779,6 +812,55 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) { 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 { @@ -801,43 +883,11 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body") 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 := 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 - } - 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(req.Model, clientWantsStream, nil, "upstream_error", start) + resp, ok := s.callUpstream(w, r, creds, req.Model, body, clientWantsStream, start) + if !ok { return } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - 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(req.Model, clientWantsStream, nil, "upstream_error", start) - return - } if clientWantsStream { usage, status := s.proxyStream(w, resp) s.record(req.Model, true, usage, status, start) @@ -921,6 +971,33 @@ func sseDataPayload(line string) string { 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 @@ -935,27 +1012,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod } toolCalls := map[int]*toolCall{} err := forEachSSEChunk(resp.Body, func(chunk []byte) error { - var event 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"` - } + var event chatStreamChunk if err := json.Unmarshal(chunk, &event); err != nil { return err } @@ -998,11 +1055,14 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod } 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 := 0; i < len(toolCalls); i++ { - if call := toolCalls[i]; call != nil { - ordered = append(ordered, call) - } + for _, i := range indices { + ordered = append(ordered, toolCalls[i]) } message["tool_calls"] = ordered if content == "" { @@ -1029,11 +1089,7 @@ 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() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || !strings.HasPrefix(line, "data:") { - continue - } - payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + payload := sseDataPayload(scanner.Text()) if payload == "" || payload == "[DONE]" { continue } @@ -1041,14 +1097,15 @@ func forEachSSEChunk(r io.Reader, fn func([]byte) error) error { if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" { return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"]) } - if !json.Valid([]byte(payload)) { - return errors.New("zhanlu stream contained invalid JSON") - } if err := fn([]byte(payload)); err != nil { return err } } - return scanner.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) { @@ -1116,13 +1173,6 @@ func mask(s string) string { return s[:4] + "****" + s[len(s)-4:] } -func firstNonEmpty(a, b string) string { - if a != "" { - return a - } - return b -} - // 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, @@ -1174,576 +1224,3 @@ func redactQueryValue(s, key string) string { s = s[:valueStart] + "" + s[valueEnd:] } } - -var loginTemplate = template.Must(template.New("login").Parse(` - - - - - 湛卢代理登录 - - - -
-
Zhanlu Proxy
-

湛卢代理登录

-

请输入服务环境变量 ZHANLU_LOGIN_PASSWORD 配置的管理密码,验证后进入管理后台。

-
- - -
-
需要登录后才能管理湛卢凭据。
-
- - -`)) - -var loginResultTemplate = template.Must(template.New("login-result").Parse(` - - - - - 湛卢登录结果 - - - -
-
- {{if .Success}}{{else}}{{end}} -
- {{if .Success}}

登录成功

{{else}}

登录失败

{{end}} -

{{.Message}}

- 返回管理后台 -
- -`)) - -var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{ - "pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }, - "rate": func(cached, prompt int64) string { - if prompt <= 0 { - return "0%" - } - return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100) - }, - "human": humanNum, -}).Parse(` - - - - - 湛卢代理管理后台 - - - -
-
-
-
Zhanlu Proxy · 管理后台
-

湛卢代理管理

-
-
{{if .PasswordEnabled}}{{end}}
-
- -
- - - -
- -
-
- - -
- {{if not .Enabled}}

统计已关闭(ZHANLU_STATS_DISABLED=true)。

{{end}} -
-

总览

-
-
请求总数
{{.Stats.Totals.Requests}}
成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}
-
Prompt Tokens
{{human .Stats.Totals.PromptTokens}}
缓存 {{human .Stats.Totals.CachedTokens}}
-
Completion Tokens
{{human .Stats.Totals.CompletionTokens}}
含思考 {{human .Stats.Totals.ReasoningTokens}}
-
Total Tokens
{{human .Stats.Totals.TotalTokens}}
-
缓存命中率
{{pct .Stats.Totals.CacheRate}}
缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}
-
-
-
-

按模型

- {{if .Stats.PerModel}} -
- - - {{range .Stats.PerModel}}{{end}} - -
模型请求数PromptCompTotal缓存命中率
{{.Model}}{{.Requests}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{rate .CachedTokens .PromptTokens}}
- {{else}}

暂无数据

{{end}} -
-
-

按日

- {{if .Stats.Daily}} -
- {{range .Stats.Daily}}
{{.Day}}
{{human .TotalTokens}}
{{end}} -
- {{else}}

暂无数据

{{end}} -
-
-

最近请求

- {{if .Stats.Recent}} -
- - - {{range .Stats.Recent}}{{end}} - -
时间模型模式PromptCompTotal缓存状态耗时
{{.Ts.Format "01-02 15:04:05"}}{{.Model}}{{if .Stream}}流式{{else}}非流式{{end}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{if eq .Status "success"}}成功{{else}}失败{{end}}{{.LatencyMs}}ms
-
- {{else}}

暂无数据

{{end}} -
-
- -
-
- - -
-
-

可用模型

-

点击刷新获取当前上游接口返回的模型列表。

-
- - - -
模型状态首字延时总耗时操作
-
-
-
- -
- -
-
- - -`)) diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..046c583 --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,49 @@ +package server + +import ( + "embed" + "fmt" + "html/template" +) + +// templateFS holds the rendered admin/login HTML pages. Keeping them as +// separate files (rather than inline raw-string literals in server.go) gives +// them real syntax highlighting and keeps server.go focused on handlers. +// +//go:embed templates/*.html +var templateFS embed.FS + +var ( + loginTemplate = mustParseTemplate("login.html", "login") + loginResultTemplate = mustParseTemplate("login_result.html", "login-result") + adminTemplate = mustParseTemplateFuncs("admin.html", "admin", template.FuncMap{ + "pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }, + "rate": func(cached, prompt int64) string { + if prompt <= 0 { + return "0%" + } + return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100) + }, + "human": humanNum, + }) +) + +// mustParseTemplate reads a single embedded template file and parses it, +// panicking on error (a malformed template is a build-time mistake). +func mustParseTemplate(filename, name string) *template.Template { + data, err := templateFS.ReadFile("templates/" + filename) + if err != nil { + panic("embed template " + filename + ": " + err.Error()) + } + return template.Must(template.New(name).Parse(string(data))) +} + +// mustParseTemplateFuncs is mustParseTemplate with a FuncMap registered before +// parsing, so the template body may reference the custom functions. +func mustParseTemplateFuncs(filename, name string, funcs template.FuncMap) *template.Template { + data, err := templateFS.ReadFile("templates/" + filename) + if err != nil { + panic("embed template " + filename + ": " + err.Error()) + } + return template.Must(template.New(name).Funcs(funcs).Parse(string(data))) +} diff --git a/internal/server/templates/admin.html b/internal/server/templates/admin.html new file mode 100644 index 0000000..2f25421 --- /dev/null +++ b/internal/server/templates/admin.html @@ -0,0 +1,439 @@ + + + + + + 湛卢代理管理后台 + + + +
+
+
+
Zhanlu Proxy · 管理后台
+

湛卢代理管理

+
+
{{if .PasswordEnabled}}{{end}}
+
+ +
+ + + +
+ +
+
+ + +
+ {{if not .Enabled}}

统计已关闭(ZHANLU_STATS_DISABLED=true)。

{{end}} +
+

总览

+
+
请求总数
{{.Stats.Totals.Requests}}
成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}
+
Prompt Tokens
{{human .Stats.Totals.PromptTokens}}
缓存 {{human .Stats.Totals.CachedTokens}}
+
Completion Tokens
{{human .Stats.Totals.CompletionTokens}}
含思考 {{human .Stats.Totals.ReasoningTokens}}
+
Total Tokens
{{human .Stats.Totals.TotalTokens}}
+
缓存命中率
{{pct .Stats.Totals.CacheRate}}
缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}
+
+
+
+

按模型

+ {{if .Stats.PerModel}} +
+ + + {{range .Stats.PerModel}}{{end}} + +
模型请求数PromptCompTotal缓存命中率
{{.Model}}{{.Requests}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{rate .CachedTokens .PromptTokens}}
+ {{else}}

暂无数据

{{end}} +
+
+

按日

+ {{if .Stats.Daily}} +
+ {{range .Stats.Daily}}
{{.Day}}
{{human .TotalTokens}}
{{end}} +
+ {{else}}

暂无数据

{{end}} +
+
+

最近请求

+ {{if .Stats.Recent}} +
+ + + {{range .Stats.Recent}}{{end}} + +
时间模型模式PromptCompTotal缓存状态耗时
{{.Ts.Format "01-02 15:04:05"}}{{.Model}}{{if .Stream}}流式{{else}}非流式{{end}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{if eq .Status "success"}}成功{{else}}失败{{end}}{{.LatencyMs}}ms
+
+ {{else}}

暂无数据

{{end}} +
+
+ +
+
+ + +
+
+

可用模型

+

点击刷新获取当前上游接口返回的模型列表。

+
+ + + +
模型状态首字延时总耗时操作
+
+
+
+ +
+ +
+
+ + + \ No newline at end of file diff --git a/internal/server/templates/login.html b/internal/server/templates/login.html new file mode 100644 index 0000000..9f0bb00 --- /dev/null +++ b/internal/server/templates/login.html @@ -0,0 +1,81 @@ + + + + + + 湛卢代理登录 + + + +
+
Zhanlu Proxy
+

湛卢代理登录

+

请输入服务环境变量 ZHANLU_LOGIN_PASSWORD 配置的管理密码,验证后进入管理后台。

+
+ + +
+
需要登录后才能管理湛卢凭据。
+
+ + + \ No newline at end of file diff --git a/internal/server/templates/login_result.html b/internal/server/templates/login_result.html new file mode 100644 index 0000000..53c337e --- /dev/null +++ b/internal/server/templates/login_result.html @@ -0,0 +1,41 @@ + + + + + + 湛卢登录结果 + + + +
+
+ {{if .Success}}{{else}}{{end}} +
+ {{if .Success}}

登录成功

{{else}}

登录失败

{{end}} +

{{.Message}}

+ 返回管理后台 +
+ + \ No newline at end of file diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..9697e43 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,17 @@ +// Package util holds small shared helpers used across internal packages to +// avoid divergent same-named copies. +package util + +import "strings" + +// FirstNonEmpty returns the first trimmed-non-empty value, or "" when none of +// the values are non-empty. Callers that need a specific fallback for the +// all-empty case should apply it explicitly at the call site. +func FirstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/internal/zhanlu/client.go b/internal/zhanlu/client.go index b413354..eb9ea6e 100644 --- a/internal/zhanlu/client.go +++ b/internal/zhanlu/client.go @@ -13,6 +13,7 @@ import ( "git.misaka.ren/M1saka/zhanlu_proxy/internal/auth" "git.misaka.ren/M1saka/zhanlu_proxy/internal/sign" + "git.misaka.ren/M1saka/zhanlu_proxy/internal/util" ) type Client struct { @@ -93,8 +94,8 @@ func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team if strings.TrimSpace(email) == "" { return "", fmt.Errorf("profile email is required to provision an API key") } - org := firstNonEmpty(organization, "未配置") - tm := firstNonEmpty(team, "未配置") + org := util.FirstNonEmpty(organization, "未配置") + tm := util.FirstNonEmpty(team, "未配置") // Field order matters: the SM2 signature covers the exact JSON body bytes, // matching the plugin's JSON.stringify({email, organization, team}). body, err := json.Marshal(struct { @@ -195,7 +196,7 @@ func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) { seen := map[string]bool{} models := make([]string, 0, len(out.Data)) for _, m := range out.Data { - id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID) + id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID) if id == "" || seen[id] { continue } @@ -237,15 +238,6 @@ func findString(m map[string]any, keys ...string) string { return "" } -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" func randomAlnum(n int) string {