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
+8 -19
View File
@@ -33,17 +33,16 @@ func DecryptCredential(ciphertextBase64, key string) (string, error) {
return string(plain), nil
}
func EncryptCredential(plaintext, key string) (string, error) {
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
if err != nil {
return "", err
// DecryptCredentialOrRaw mirrors the plugin's z4A: try AES-ECB decrypt with the
// given key, falling back to the raw value when the field is plaintext.
func DecryptCredentialOrRaw(value, key string) string {
if value == "" {
return ""
}
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
out := make([]byte, len(plain))
for start := 0; start < len(plain); start += aes.BlockSize {
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
if plain, err := DecryptCredential(value, key); err == nil && plain != "" {
return plain
}
return base64.StdEncoding.EncodeToString(out), nil
return value
}
func repeatKey(key string, size int) []byte {
@@ -73,13 +72,3 @@ func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
}
return in[:len(in)-pad], nil
}
func padPKCS7(in []byte, blockSize int) []byte {
pad := blockSize - len(in)%blockSize
out := make([]byte, len(in)+pad)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+25 -5
View File
@@ -10,14 +10,30 @@ import (
)
type Credentials struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
APIKey string `json:"api_key,omitempty"`
ModelBaseURL string `json:"model_base_url,omitempty"`
Email string `json:"email,omitempty"`
Organization string `json:"organization,omitempty"`
Team string `json:"team,omitempty"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
type Profile struct {
Email string
Organization string
Team string
UserName string
Telephone string
}
func (c Credentials) Validate() error {
if strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" {
return nil
}
if strings.TrimSpace(c.AccessKey) == "" {
return errors.New("access_key is required")
}
@@ -30,6 +46,10 @@ func (c Credentials) Validate() error {
return nil
}
func (c Credentials) HasAPIKey() bool {
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
}
func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path)
if err != nil {
+36
View File
@@ -0,0 +1,36 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
)
// SignSM2Authorization signs `message` with the SM2 private key in hex form
// (mirroring the Zhanlu plugin: SM3 digest signed with hash:false, der:false,
// output as 64-byte r||s hex).
func SignSM2Authorization(privateKeyHex, message string) (string, error) {
keyHex := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
return "", err
}
priv, err := sm2.NewPrivateKey(keyBytes)
if err != nil {
return "", err
}
digest := sm3.Sum([]byte(message))
r, s, err := sm2.Sign(rand.Reader, &priv.PrivateKey, digest[:])
if err != nil {
return "", err
}
rb := r.FillBytes(make([]byte, 32))
sb := s.FillBytes(make([]byte, 32))
return hex.EncodeToString(append(rb, sb...)), nil
}
var errEmptySM2Key = errors.New("SM2 private key is required")
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"encoding/hex"
"strings"
"testing"
)
func TestSignSM2Authorization(t *testing.T) {
const privHex = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
sig, err := SignSM2Authorization(privHex, "1754460000:AbCdEfGh1234567890AbCdEfGh123456:{\"email\":\"[email protected]\"}")
if err != nil {
t.Fatalf("SignSM2Authorization: %v", err)
}
if len(sig) != 128 {
t.Fatalf("signature length = %d, want 128 (r||s hex)", len(sig))
}
if _, err := hex.DecodeString(sig); err != nil {
t.Fatalf("signature is not hex: %v", err)
}
// Deterministic inputs must produce a stable signature across calls only if
// the nonce is fixed; sm-crypto randomizes k, so just check shape + parse.
if strings.TrimSpace(sig) != sig {
t.Fatalf("signature contains whitespace")
}
}
func TestSignSM2AuthorizationInvalidKey(t *testing.T) {
if _, err := SignSM2Authorization("zz", "x"); err == nil {
t.Fatal("expected error for invalid private key hex")
}
}
+54 -38
View File
@@ -10,74 +10,90 @@ import (
"time"
)
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
Body map[string]any `json:"body"`
}
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
// gateway authToken endpoint (POST /api/acepilot/zhanlu/authToken). The
// profile fields may be AES-ECB encrypted with the token decrypt key; each
// field falls back to the raw value when decryption fails.
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Profile, error) {
if strings.TrimSpace(endpoint) == "" {
return Credentials{}, errors.New("exchange endpoint is required")
return Profile{}, errors.New("exchange endpoint is required")
}
if strings.TrimSpace(code) == "" {
return Credentials{}, errors.New("code is required")
}
if strings.TrimSpace(decryptKey) == "" {
return Credentials{}, errors.New("decrypt key is required")
return Profile{}, errors.New("code is required")
}
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
// HTTP/1.1 only: the Zhanlu gateway drops HTTP/2 negotiation.
client = &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ForceAttemptHTTP2: false}}
}
body, _ := json.Marshal(map[string]string{"code": code})
body, _ := json.Marshal(map[string]string{"deputyAccountNumber": code})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Credentials{}, err
return Profile{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return Credentials{}, err
return Profile{}, err
}
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])))
}
var exchange ExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
return Credentials{}, err
return Profile{}, err
}
if exchange.ErrorCode != "Success" {
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
if exchange.State != "" && exchange.State != "OK" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
if err != nil {
return Credentials{}, err
profile := Profile{}
for _, m := range []map[string]any{exchange.Body, exchange.Result, exchange.Data} {
if m == nil {
continue
}
profile.Email = decryptProfileField(m, "email", decryptKey)
profile.Organization = decryptProfileField(m, "organization", decryptKey)
profile.Team = decryptProfileField(m, "team", decryptKey)
profile.UserName = decryptProfileField(m, "name", decryptKey)
profile.Telephone = decryptProfileField(m, "telephone", decryptKey)
if profile.Email != "" || profile.Organization != "" || profile.Team != "" {
return profile, nil
}
}
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
if err != nil {
return Credentials{}, err
}
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
if err != nil {
return Credentials{}, err
}
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
return Profile{}, errors.New("exchange response body missing profile fields")
}
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
v, ok := body[key]
func decryptProfileField(m map[string]any, key, decryptKey string) string {
v, ok := m[key]
if !ok {
return "", fmt.Errorf("response body missing %s", key)
return ""
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("response body %s is not a string", key)
if !ok {
return ""
}
return DecryptCredential(strings.TrimSpace(s), decryptKey)
return DecryptCredentialOrRaw(strings.TrimSpace(s), decryptKey)
}
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
State string `json:"state"`
Body map[string]any `json:"body"`
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
func firstNonEmpty(values ...string) string {
+54
View File
@@ -0,0 +1,54 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExchangeCodeAuthTokenFlow(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/authToken", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s", r.Method)
}
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
if in["deputyAccountNumber"] != "dep-123" {
t.Errorf("deputyAccountNumber = %q", in["deputyAccountNumber"])
}
// plaintext profile (DecryptCredentialOrRaw fallback)
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "org", "team": "team",
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
profile, err := ExchangeCode(&http.Client{}, ts.URL+"/api/acepilot/zhanlu/authToken", "dep-123", "3jw7woww2rvhla6k")
if err != nil {
t.Fatalf("ExchangeCode: %v", err)
}
if profile.Email != "[email protected]" || profile.Organization != "org" || profile.Team != "team" {
t.Fatalf("profile = %+v", profile)
}
}
func TestExchangeCodeMissingFields(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) {
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
if _, err := ExchangeCode(&http.Client{}, ts.URL+"/x", "dep", ""); err == nil {
t.Fatal("expected error for empty profile")
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
+52 -34
View File
@@ -10,50 +10,57 @@ import (
)
type Config struct {
ListenAddr string
ServerBaseURL string
UpstreamPath string
CredentialsPath string
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
Models []string
DefaultModel string
OpenAIAPIKey string
LoginPassword string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
Debug bool
Credentials auth.Credentials
ListenAddr string
MobileLoginBaseURL string
MobileModelBaseURL string
UpstreamPath string
CredentialsPath string
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
SM2PrivateKey string
Models []string
DefaultModel string
OpenAIAPIKey string
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
Debug bool
Credentials auth.Credentials
}
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
MobileLoginBaseURL: 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"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"),
TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
SM2PrivateKey: getenv("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY", defaultSM2PrivateKey),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "GLM-4.7"),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
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),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "glm47,minimax-m25"))
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "GLM-4.7,MiniMax-M2.5"))
cfg.Credentials = auth.Credentials{
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
Token: os.Getenv("ZHANLU_TOKEN"),
APIKey: os.Getenv("ZHANLU_API_KEY"),
}
if cfg.Credentials.Validate() == nil {
if cfg.Credentials.Validate() == nil || cfg.Credentials.HasAPIKey() {
return cfg, nil
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
@@ -93,6 +100,15 @@ 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 ""
}
func defaultCredentialsPath() string {
return filepath.Join(".", "credentials.json")
}
@@ -104,3 +120,5 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUM
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
-----END PUBLIC KEY-----`
const defaultSM2PrivateKey = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
+1 -11
View File
@@ -28,21 +28,11 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
}
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
model := map[string]string{
"minimax-m2.5": "minimax-m25",
"glm4.7": "glm47",
}[r.Model]
if model == "" {
model = r.Model
}
m := map[string]any{
"model": model,
"model": r.Model,
"messages": r.Messages,
"temperature": 0,
"stream": r.Stream,
"stream_options": map[string]any{"include_usage": true},
"max_tokens": 16000,
"inputs": map[string]any{"aiDevQuestion": ""},
}
for k, v := range r.Extra {
var anyValue any
+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>
+206
View File
@@ -0,0 +1,206 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to it.
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/query/acepilot-h5/manager/code/getAuthCode":
writeJSON(w, http.StatusOK, map[string]any{"state": "OK"})
case "/api/query/acepilot-h5/manager/code/checkCode":
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
"result": true,
"ak": "BASE64AK", "sk": "BASE64SK", "license": "BASE64TOKEN",
}})
case "/api/acepilot/zhanlu/v1/login":
if r.Header.Get("plugin_type") != "zhanlu_ide" {
writeJSON(w, http.StatusBadRequest, map[string]any{"state": "ERROR", "errorMessage": "bad plugin_type"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "cmcc", "team": "ai",
}})
case "/user/api/v2/external/key/get-or-create":
writeJSON(w, http.StatusOK, map[string]any{"apiKey": "sk-test-456"})
case "/chat/completions":
if r.Header.Get("Authorization") != "Bearer sk-test-456" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"message": "bad auth"}})
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":1}}\n\n")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
case "/gateway/v1/model/info":
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{
{"model_name": "GLM-4.7"}, {"id": "MiniMaxAI/MiniMax-M2.5"},
}})
default:
http.NotFound(w, r)
}
}))
credsFile := filepath.Join(t.TempDir(), "credentials.json")
cfg := config.Config{
ListenAddr: ":0",
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
CredentialsPath: credsFile,
TokenDecryptKey: "3jw7woww2rvhla6k",
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
Models: []string{"GLM-4.7", "MiniMaxAI/MiniMax-M2.5"},
DefaultModel: "GLM-4.7",
PluginVersion: "1.4.2",
}
h := New(cfg)
proxy := httptest.NewServer(h)
return upstream, proxy, credsFile
}
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
func TestPhoneLoginAndChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// 1. request phone code
resp, err := http.Post(proxy.URL+"/api/auth/code", "application/json", strings.NewReader(`{"telephone":"13800000000"}`))
if err != nil {
t.Fatal(err)
}
var codeResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&codeResp)
resp.Body.Close()
secret, _ := codeResp["secret"].(string)
// 2. login with phone code (server RSA-encrypts the telephone itself)
loginBody, _ := json.Marshal(map[string]string{"telephone": "13800000000", "code": "123456", "secret": secret})
resp, err = http.Post(proxy.URL+"/api/auth/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
t.Fatal(err)
}
var loginResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&loginResp)
resp.Body.Close()
if !okValue(loginResp) {
t.Fatalf("login failed: %v", loginResp)
}
// 3. credentials file should contain the provisioned api key
creds, err := auth.LoadCredentials(credsFile)
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "sk-test-456" {
t.Fatalf("apiKey = %q", creds.APIKey)
}
if creds.Email != "[email protected]" {
t.Fatalf("email = %q", creds.Email)
}
// 4. non-streaming chat completion
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var chatResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
t.Fatal(err)
}
choices, _ := chatResp["choices"].([]any)
if len(choices) != 1 {
t.Fatalf("chat choices = %v", chatResp)
}
msg, _ := choices[0].(map[string]any)["message"].(map[string]any)
if msg["content"] != "hello" {
t.Fatalf("chat content = %v", msg)
}
// 5. models endpoint should prefer gateway model info
resp, err = http.Get(proxy.URL + "/v1/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var modelsResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&modelsResp)
items, _ := modelsResp["data"].([]any)
if len(items) != 2 {
t.Fatalf("models = %v", modelsResp)
}
}
func TestStreamingChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// Seed credentials directly with the api key
creds := auth.Credentials{
AccessKey: "AK",
SecretKey: "SK",
Token: "TOKEN",
APIKey: "sk-test-456",
ModelBaseURL: upstream.URL,
Email: "[email protected]",
}
if err := auth.SaveCredentials(credsFile, creds); err != nil {
t.Fatal(err)
}
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
if !strings.Contains(body, "data: ") || !strings.Contains(body, "hello") || !strings.Contains(body, "[DONE]") {
t.Fatalf("stream body: %s", body)
}
}
func okValue(m map[string]any) bool {
ok, _ := m["ok"].(bool)
return ok
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
var _ = context.Background
const defaultTestPublicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+3 -1
View File
@@ -46,7 +46,9 @@ func (s Signer) BuildOpURL(path string, creds auth.Credentials, baseURL string,
if err != nil {
return "", err
}
timestamp := now().Format("2006-01-02T15:04:05Z")
// The plugin formats Beijing time (UTC+8) with a Z suffix via
// new Date(now.getTime()+8*3600*1000) and getUTC* accessors.
timestamp := now().Add(8 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
query := "AccessKey=" + creds.AccessKey +
"&SignatureMethod=HmacSHA1" +
"&SignatureNonce=" + nonce() +
+74
View File
@@ -0,0 +1,74 @@
package sign
import (
"encoding/hex"
"net/url"
"strings"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
const testPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
func TestBuildOpURLStructure(t *testing.T) {
pub, err := auth.ParsePublicKey(testPublicKeyPEM)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 5, 2, 30, 0, 0, time.UTC)
s := Signer{
PublicKey: pub,
Now: func() time.Time { return now },
Nonce: func() string { return "fixed-nonce-1234567890" },
}
creds := auth.Credentials{AccessKey: "AK123", SecretKey: "SK456", Token: "TOK789"}
u, err := s.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, "https://ecloud.10086.cn", "POST")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(u, "https://ecloud.10086.cn/api/acepilot/zhanlu/v1/login?") {
t.Fatalf("url = %s", u)
}
parsed, err := url.Parse(u)
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if q.Get("AccessKey") != "AK123" {
t.Errorf("AccessKey = %q", q.Get("AccessKey"))
}
if q.Get("SignatureMethod") != "HmacSHA1" {
t.Errorf("SignatureMethod = %q", q.Get("SignatureMethod"))
}
if q.Get("SignatureVersion") != "V2.0" {
t.Errorf("SignatureVersion = %q", q.Get("SignatureVersion"))
}
if q.Get("Version") != "2016-12-05" {
t.Errorf("Version = %q", q.Get("Version"))
}
// Beijing time (UTC+8) formatted with Z suffix, matching the plugin's yE9.
if q.Get("Timestamp") != "2026-08-05T10:30:00Z" {
t.Errorf("Timestamp = %q, want 2026-08-05T10:30:00Z (Beijing time)", q.Get("Timestamp"))
}
if q.Get("Signature") == "" {
t.Error("Signature is empty")
}
if _, err := hex.DecodeString(q.Get("Signature")); err != nil {
t.Errorf("Signature is not hex: %v", err)
}
authz := q.Get("authorization")
if authz == "" {
t.Error("authorization is empty")
}
}
func TestBuildOpURLMissingCreds(t *testing.T) {
s := Signer{}
if _, err := s.BuildOpURL("/x", auth.Credentials{}, "http://x", "POST"); err == nil {
t.Fatal("expected error for missing credentials")
}
}
+226 -28
View File
@@ -4,8 +4,11 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
@@ -13,52 +16,247 @@ import (
)
type Client struct {
BaseURL string
Path string
Creds auth.Credentials
Signer sign.Signer
HTTPClient *http.Client
LoginBaseURL string
ModelBaseURL string
ChatPath string
PluginVersion string
SM2PrivateKey string
Signer sign.Signer
HTTPClient *http.Client
}
func NewClient(baseURL, path string, creds auth.Credentials, signer sign.Signer, timeout time.Duration) *Client {
func NewClient(loginBaseURL, modelBaseURL, chatPath, pluginVersion, sm2PrivateKey string, signer sign.Signer, timeout time.Duration) *Client {
// The Zhanlu gateway rejects HTTP/2 ALPN negotiation (connection EOF on
// handshake), so force HTTP/1.1 for all upstream requests.
transport := &http.Transport{ForceAttemptHTTP2: false}
return &Client{
BaseURL: baseURL,
Path: path,
Creds: creds,
Signer: signer,
HTTPClient: &http.Client{
Timeout: timeout,
},
LoginBaseURL: loginBaseURL,
ModelBaseURL: modelBaseURL,
ChatPath: chatPath,
PluginVersion: pluginVersion,
SM2PrivateKey: sm2PrivateKey,
Signer: signer,
HTTPClient: &http.Client{Timeout: timeout, Transport: transport},
}
}
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) {
baseURL := c.BaseURL
if c.Creds.BaseURL != "" {
baseURL = c.Creds.BaseURL
}
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
// LoginProfile validates AK/SK/token against the Zhanlu gateway
// (POST /api/acepilot/zhanlu/v1/login) and returns the decrypted user profile.
func (c *Client) LoginProfile(ctx context.Context, creds auth.Credentials) (auth.Profile, error) {
signedURL, err := c.Signer.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, c.LoginBaseURL, http.MethodPost)
if err != nil {
return nil, err
return auth.Profile{}, err
}
encryptedBody, err := auth.EncryptCredential(string(body), c.Creds.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, strings.NewReader(`{}`))
if err != nil {
return nil, err
return auth.Profile{}, err
}
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody))
c.setPluginHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return auth.Profile{}, err
}
defer resp.Body.Close()
var out struct {
State string `json:"state"`
Body struct {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
Name string `json:"name"`
Telephone string `json:"telephone"`
} `json:"body"`
}
if err := decodeJSON(resp, &out); err != nil {
return auth.Profile{}, err
}
if out.State != "OK" {
return auth.Profile{}, fmt.Errorf("zhanlu login failed: state=%s", out.State)
}
return auth.Profile{
Email: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Email), creds.Token),
Organization: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Organization), creds.Token),
Team: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Team), creds.Token),
UserName: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Name), creds.Token),
Telephone: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Telephone), creds.Token),
}, nil
}
// ProvisionAPIKey requests a Zhanlu gateway API key
// (POST {modelBaseUrl}/user/api/v2/external/key/get-or-create) using the
// SM2-signed X-Auth-* headers, then returns the apiKey. Empty organization and
// team default to the plugin's "未配置" placeholder.
func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team string) (string, error) {
if strings.TrimSpace(c.SM2PrivateKey) == "" {
return "", fmt.Errorf("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY is required to provision an API key")
}
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := firstNonEmpty(organization, "未配置")
tm := 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 {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
}{email, org, tm})
if err != nil {
return "", err
}
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/user/api/v2/external/key/get-or-create"
timestamp := fmt.Sprintf("%d", time.Now().Unix())
nonce := randomAlnum(32)
signature, err := auth.SignSM2Authorization(c.SM2PrivateKey, timestamp+":"+nonce+":"+string(body))
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Signature", signature)
req.Header.Set("X-Auth-Timestamp", timestamp)
req.Header.Set("X-Auth-Nonce", nonce)
resp, err := c.HTTPClient.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("zhanlu api key provisioning returned %d: %s", resp.StatusCode, string(b))
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
apiKey := findString(out, "apiKey", "key", "api_key")
if apiKey == "" {
return "", fmt.Errorf("zhanlu api key provisioning response missing apiKey")
}
return apiKey, nil
}
// ChatCompletions posts an OpenAI-compatible body to the Zhanlu gateway chat
// endpoint authenticated with the provisioned API key.
func (c *Client) ChatCompletions(ctx context.Context, apiKey string, body []byte) (*http.Response, error) {
baseURL := c.ModelBaseURL
path := c.ChatPath
if path == "" {
path = "/chat/completions"
}
endpoint := strings.TrimRight(baseURL, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("service_type", "code")
req.Header.Set("request", randomRequestID())
req.Header.Set("Authorization", "Bearer "+apiKey)
return c.HTTPClient.Do(req)
}
// Models lists model ids exposed by the gateway model-info endpoint
// (GET {modelBaseUrl}/gateway/v1/model/info) with the provisioned API key.
func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/gateway/v1/model/info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("zhanlu model info returned %d: %s", resp.StatusCode, string(b))
}
var out struct {
Data []struct {
ModelName string `json:"model_name"`
ID string `json:"id"`
ModelInfo struct {
ID string `json:"id"`
} `json:"model_info"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
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)
if id == "" || seen[id] {
continue
}
seen[id] = true
models = append(models, id)
}
return models, nil
}
func (c *Client) setPluginHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", c.PluginVersion)
req.Header.Set("request", randomRequestID())
}
func decodeJSON(resp *http.Response, out any) error {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("zhanlu upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func findString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
// The plugin searches the root and the data/body/result/payload containers.
for _, container := range []string{"data", "body", "result", "payload"} {
if v, ok := m[container].(map[string]any); ok {
if s := findString(v, keys...); s != "" {
return s
}
}
}
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 {
b := make([]byte, n)
rand.Read(b)
for i := range b {
b[i] = alnum[int(b[i])%len(alnum)]
}
return string(b)
}
func randomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
+143
View File
@@ -0,0 +1,143 @@
package zhanlu
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
func TestClientFlow(t *testing.T) {
var gotLoginHeaders, gotProvisionHeaders http.Header
var gotChatAuth, gotModelsAuth string
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/v1/login", func(w http.ResponseWriter, r *http.Request) {
gotLoginHeaders = r.Header
// echo a profile whose fields are plaintext (DecryptCredentialOrRaw fallback)
writeJSON(t, w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "cmcc", "team": "ai", "name": "Dev", "telephone": "13800000000",
}})
})
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
gotProvisionHeaders = r.Header
if gotProvisionHeaders.Get("X-Auth-Signature") == "" || gotProvisionHeaders.Get("X-Auth-Timestamp") == "" || gotProvisionHeaders.Get("X-Auth-Nonce") == "" {
t.Errorf("provision request missing X-Auth-* headers: %v", gotProvisionHeaders)
}
if gotProvisionHeaders.Get("X-Auth-Nonce") == "" || len(gotProvisionHeaders.Get("X-Auth-Nonce")) != 32 {
t.Errorf("X-Auth-Nonce should be 32 chars")
}
writeJSON(t, w, map[string]any{"apiKey": "sk-zhanlu-test-123"})
})
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
gotChatAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
})
mux.HandleFunc("/gateway/v1/model/info", func(w http.ResponseWriter, r *http.Request) {
gotModelsAuth = r.Header.Get("Authorization")
writeJSON(t, w, map[string]any{"data": []map[string]any{
{"model_name": "GLM-4.7"},
{"id": "MiniMaxAI/MiniMax-M2.5"},
{"model_info": map[string]any{"id": "qwen-max"}},
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
pub, err := auth.ParsePublicKey(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`)
if err != nil {
t.Fatal(err)
}
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{PublicKey: pub}, 0)
creds := auth.Credentials{AccessKey: "AK", SecretKey: "SK", Token: "TOKEN"}
profile, err := c.LoginProfile(context.Background(), creds)
if err != nil {
t.Fatalf("LoginProfile: %v", err)
}
if profile.Email != "[email protected]" {
t.Fatalf("profile email = %q", profile.Email)
}
if gotLoginHeaders.Get("plugin_type") != "zhanlu_ide" {
t.Errorf("plugin_type header = %q", gotLoginHeaders.Get("plugin_type"))
}
if gotLoginHeaders.Get("plugin_version") != "1.4.2" {
t.Errorf("plugin_version header = %q", gotLoginHeaders.Get("plugin_version"))
}
apiKey, err := c.ProvisionAPIKey(context.Background(), profile.Email, profile.Organization, profile.Team)
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "sk-zhanlu-test-123" {
t.Fatalf("apiKey = %q", apiKey)
}
resp, err := c.ChatCompletions(context.Background(), apiKey, []byte(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}]}`))
if err != nil {
t.Fatalf("ChatCompletions: %v", err)
}
defer resp.Body.Close()
if gotChatAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("chat Authorization = %q", gotChatAuth)
}
models, err := c.Models(context.Background(), apiKey)
if err != nil {
t.Fatalf("Models: %v", err)
}
if len(models) != 3 || models[0] != "GLM-4.7" || models[1] != "MiniMaxAI/MiniMax-M2.5" || models[2] != "qwen-max" {
t.Fatalf("models = %v", models)
}
if gotModelsAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("models Authorization = %q", gotModelsAuth)
}
}
func TestProvisionAPIKeyDefaultsPlaceholders(t *testing.T) {
var body string
mux := http.NewServeMux()
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
body = strings.TrimSpace(string(buf[:n]))
writeJSON(t, w, map[string]any{"data": map[string]any{"key": "k2"}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{}, 0)
apiKey, err := c.ProvisionAPIKey(context.Background(), "[email protected]", "", "")
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "k2" {
t.Fatalf("apiKey = %q", apiKey)
}
var parsed map[string]string
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatal(err)
}
if parsed["organization"] != "未配置" || parsed["team"] != "未配置" {
t.Fatalf("placeholders not applied: %v", parsed)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}