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:
+161
-66
@@ -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>
|
||||
|
||||
@@ -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-----`
|
||||
Reference in New Issue
Block a user