Update proxy to Zhanlu v1.4.2 provider flow

The 1.4.2 extension replaced the old signed/encrypted chat gateway with an
OpenAI-compatible aigateway. Align the proxy with the new flow:

- Use ecloud.10086.cn login/model base URLs, zhanlu_ide plugin headers and
  v1.4.2 plugin version
- Provision the model API key via SM2-signed get-or-create after v1/login
  profile fetch; store api_key/model_base_url/email in credentials
- Chat via Bearer apiKey against {modelBaseUrl}/chat/completions with plain
  OpenAI SSE passthrough; fetch /v1/models from the gateway model-info endpoint
- Force HTTP/1.1 upstream (gateway drops HTTP/2 ALPN negotiation with EOF)
- Drop obsolete AES body encryption, model name mapping and vscode headers
This commit is contained in:
2026-08-05 14:55:28 +08:00
parent c3af3caa5b
commit 7d8a5b6f74
18 changed files with 1122 additions and 232 deletions
+161 -66
View File
@@ -2,6 +2,7 @@ package server
import (
"bufio"
"context"
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
@@ -148,12 +149,16 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
return
}
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, s.cfg.SSOExchangeURL, code, s.cfg.TokenDecryptKey)
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
}
creds.BaseURL = s.cfg.ServerBaseURL
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
s.renderLoginResult(w, false, err.Error())
return
@@ -162,6 +167,26 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
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)
@@ -218,7 +243,7 @@ func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/getAuthCode"
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()})
@@ -258,7 +283,7 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/checkCode"
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()})
@@ -273,7 +298,12 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
creds.BaseURL = s.cfg.ServerBaseURL
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 := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
@@ -282,6 +312,28 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "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
@@ -341,10 +393,10 @@ func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *p
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", s.cfg.PluginVersion)
req.Header.Set("request", randomRequestID())
resp, err := (&http.Client{Timeout: s.cfg.UpstreamTimeout}).Do(req)
resp, err := s.upstreamHTTPClient().Do(req)
if err != nil {
return err
}
@@ -362,17 +414,12 @@ func decryptPhoneCredentials(body struct {
SK string `json:"sk"`
License string `json:"license"`
}, secret string) (auth.Credentials, error) {
ak, err := auth.DecryptCredential(strings.TrimSpace(body.AK), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt access key: %w", err)
}
sk, err := auth.DecryptCredential(strings.TrimSpace(body.SK), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt secret key: %w", err)
}
token, err := auth.DecryptCredential(strings.TrimSpace(body.License), secret)
if err != nil {
return auth.Credentials{}, fmt.Errorf("decrypt token: %w", err)
// 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
}
@@ -410,11 +457,13 @@ func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"path": s.cfg.CredentialsPath,
"access_key": mask(c.AccessKey),
"base_url": c.BaseURL,
"saved_at": c.SavedAt,
"configured": true,
"path": s.cfg.CredentialsPath,
"access_key": mask(c.AccessKey),
"has_api_key": c.APIKey != "",
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
"email": c.Email,
"saved_at": c.SavedAt,
})
}
@@ -424,6 +473,15 @@ func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
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 := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
@@ -445,12 +503,19 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
}
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, endpoint, in.Code, decryptKey)
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.BaseURL = in.BaseURL
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 := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
@@ -460,8 +525,16 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
data := make([]map[string]any, 0, len(s.cfg.Models))
for _, model := range s.cfg.Models {
modelIDs := s.cfg.Models
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
}
}
}
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})
@@ -482,8 +555,6 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
req.Model = s.cfg.DefaultModel
}
clientWantsStream := req.Stream
// The Zhanlu gateway always expects streaming responses. Sending stream=false
// makes its Java adapter read choice.delta from a non-streaming choice.
req.Stream = true
body, err := req.MarshalForUpstream()
if err != nil {
@@ -491,13 +562,22 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
signer, err := s.signer()
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
_ = auth.SaveCredentials(s.cfg.CredentialsPath, 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
}
client := zhanlu.NewClient(s.cfg.ServerBaseURL, s.cfg.UpstreamPath, creds, signer, s.cfg.UpstreamTimeout)
resp, err := client.ChatCompletions(r.Context(), body)
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
@@ -517,34 +597,30 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
if clientWantsStream {
s.proxyDecryptedStream(w, resp, creds.Token)
s.proxyStream(w, resp)
return
}
s.aggregateDecryptedStream(w, resp, creds.Token, req.Model)
s.aggregateStream(w, resp, req.Model)
}
func (s *Server) proxyDecryptedStream(w http.ResponseWriter, resp *http.Response, token string) {
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
_, err := fmt.Fprintf(w, "data: %s\n\n", chunk)
if flusher != nil {
flusher.Flush()
}
return err
})
_, err := io.Copy(w, resp.Body)
if flusher != nil {
flusher.Flush()
}
if err != nil {
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}
func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Response, token, model string) {
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
@@ -557,7 +633,7 @@ func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Resp
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
@@ -645,33 +721,28 @@ func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Resp
writeJSON(w, http.StatusOK, result)
}
func forEachDecryptedChunk(r io.Reader, token string, fn func([]byte) error) error {
// 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() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
if !strings.HasPrefix(line, "data:") {
var upstreamError map[string]any
if json.Unmarshal([]byte(line), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
ciphertext := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if ciphertext == "" || ciphertext == "[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"])
}
plain, err := auth.DecryptCredential(ciphertext, token)
if err != nil {
return fmt.Errorf("decrypt zhanlu stream: %w", err)
if !json.Valid([]byte(payload)) {
return errors.New("zhanlu stream contained invalid JSON")
}
if !json.Valid([]byte(plain)) {
return errors.New("zhanlu stream contained invalid decrypted JSON")
}
if err := fn([]byte(plain)); err != nil {
if err := fn([]byte(payload)); err != nil {
return err
}
}
@@ -679,14 +750,38 @@ func forEachDecryptedChunk(r io.Reader, token string, fn func([]byte) error) err
}
func (s *Server) currentCredentials() (auth.Credentials, error) {
if s.cfg.Credentials.Validate() == nil {
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
return s.cfg.Credentials, nil
}
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
if err != nil {
return auth.Credentials{}, err
}
return c, c.Validate()
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) {
@@ -781,7 +876,7 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<section class="hero">
<div>
<h1>湛卢代理登录</h1>
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
</div>
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
</section>