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:
+226
-28
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user