From 7d8a5b6f7469700caee6f9f0d8411b51cf747c2e Mon Sep 17 00:00:00 2001 From: m1saka Date: Wed, 5 Aug 2026 14:55:28 +0800 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 64 +++++---- go.mod | 6 +- go.sum | 6 + internal/auth/aes.go | 27 ++-- internal/auth/credentials.go | 30 +++- internal/auth/sm2.go | 36 +++++ internal/auth/sm2_test.go | 32 +++++ internal/auth/sso.go | 92 +++++++----- internal/auth/sso_test.go | 54 +++++++ internal/config/config.go | 86 ++++++----- internal/openai/types.go | 12 +- internal/server/server.go | 227 ++++++++++++++++++++--------- internal/server/server_test.go | 206 ++++++++++++++++++++++++++ internal/sign/signer.go | 4 +- internal/sign/signer_test.go | 74 ++++++++++ internal/zhanlu/client.go | 254 +++++++++++++++++++++++++++++---- internal/zhanlu/client_test.go | 143 +++++++++++++++++++ 18 files changed, 1122 insertions(+), 232 deletions(-) create mode 100644 go.sum create mode 100644 internal/auth/sm2.go create mode 100644 internal/auth/sm2_test.go create mode 100644 internal/auth/sso_test.go create mode 100644 internal/server/server_test.go create mode 100644 internal/sign/signer_test.go create mode 100644 internal/zhanlu/client_test.go diff --git a/.gitignore b/.gitignore index ca08a48..a2af2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ extension/ !.env.example tmp/ temp/ +source/ diff --git a/README.md b/README.md index 97c70c3..ac208db 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Zhanlu Proxy -一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。 +一个本地 Go 代理服务,用于读取湛卢(v1.4.2)插件凭据,按插件认证规则换取模型 API Key,并暴露 OpenAI 兼容接口。 当前实现包含: -- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。 -- OpenAI 兼容接口:`/v1/models`、`/v1/chat/completions`。 -- 湛卢签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`。 -- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。 +- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。 +- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer ` 请求 `{modelBaseUrl}/chat/completions`)。 +- 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证。 +- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。 +- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。 - OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。 ## 运行 @@ -47,10 +48,10 @@ http://127.0.0.1:8080/ ```env ZHANLU_LISTEN_ADDR=:8080 ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json -ZHANLU_SERVER_BASE_URL=https://api-wuxi-1.cmecloud.cn:8443 -ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat -ZHANLU_MODELS=glm47,minimax-m25 -ZHANLU_DEFAULT_MODEL=minimax-m25 +ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn +ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway +ZHANLU_MODELS=GLM-4.7,MiniMax-M2.5 +ZHANLU_DEFAULT_MODEL=GLM-4.7 ZHANLU_UPSTREAM_TIMEOUT=300s ZHANLU_LOGIN_PASSWORD=change-this-login-password OPENAI_COMPAT_API_KEY=change-this-local-secret @@ -105,7 +106,9 @@ journalctl -u zhanlu-proxy -f - 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。 - 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`。 - 使用本次 `secret` AES 解密响应中的 `ak`、`sk`、`license`,得到 `AccessKey`、`SecretKey`、`Token`。 -- 凭据会写入 JSON 文件,后续 OpenAI 兼容接口自动读取。 +- 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`(RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)。 +- 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`。 +- 凭据(含 `apiKey`、`modelBaseUrl`、email 等)会写入 JSON 文件,后续 OpenAI 兼容接口自动使用。 默认保存到当前执行目录: @@ -119,13 +122,14 @@ credentials.json $env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json" ``` -手机号验证码登录使用 `ZHANLU_SERVER_BASE_URL`,默认公网地址来自插件配置: +手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`): ```powershell -$env:ZHANLU_SERVER_BASE_URL="https://api-wuxi-1.cmecloud.cn:8443" +$env:ZHANLU_MOBILE_LOGIN_BASE_URL="https://ecloud.10086.cn" +$env:ZHANLU_MOBILE_MODEL_BASE_URL="https://ecloud.10086.cn/api/query/aigateway" ``` -四共 SSO 的 `/auth/start` 和 `/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。 +灵犀(内网)SSO 的 `/auth/start` 和 `/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。 ## OpenAI 兼容接口 @@ -148,7 +152,7 @@ curl http://127.0.0.1:8080/v1/models ```powershell curl http://127.0.0.1:8080/v1/chat/completions ` -H "Content-Type: application/json" ` - -d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}' + -d '{"model":"GLM-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}' ``` 流式: @@ -156,7 +160,7 @@ curl http://127.0.0.1:8080/v1/chat/completions ` ```powershell curl -N http://127.0.0.1:8080/v1/chat/completions ` -H "Content-Type: application/json" ` - -d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}' + -d '{"model":"GLM-4.7","messages":[{"role":"user","content":"hello"}],"stream":true}' ``` ### 工具调用 @@ -179,19 +183,23 @@ curl http://127.0.0.1:8080/v1/models ` | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 | -| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL | -| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 | +| `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL`) | +| `ZHANLU_MOBILE_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL | +| `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 | | `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 | | `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey | | `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey | | `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token | -| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 | -| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 | -| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 | +| `ZHANLU_API_KEY` | 空 | 直接从环境变量提供已换取的模型 API Key | +| `ZHANLU_SSO_BASE_URL` | `http://4c.hq.cmcc` | 灵犀内网 SSO 备用页面 Base URL,非默认手机号登录流程 | +| `ZHANLU_SSO_EXCHANGE_URL` | `http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken` | 灵犀 SSO 换取用户资料的备用接口 | +| `ZHANLU_TOKEN_DECRYPT_KEY` | `3jw7woww2rvhla6k` | 解密 SSO 返回用户资料字段的 AES key(插件内置默认值) | | `ZHANLU_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 | | `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 | -| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 | -| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 | +| `ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY` | 插件内置 SM2 私钥 | 换取模型 API Key 时 `X-Auth-Signature` 使用的 SM2 私钥,通常不需要设置 | +| `ZHANLU_PLUGIN_VERSION` | `1.4.2` | 请求头 `plugin_version` | +| `ZHANLU_MODELS` | `GLM-4.7,MiniMax-M2.5` | `/v1/models` 回退模型列表;有凭据时优先从模型网关拉取 | +| `ZHANLU_DEFAULT_MODEL` | `GLM-4.7` | 请求未传 `model` 时使用的默认模型 | | `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 | | `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 | | `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 | @@ -202,14 +210,14 @@ curl http://127.0.0.1:8080/v1/models ` 服务启动时按以下优先级加载凭据: -1. 环境变量 `ZHANLU_ACCESS_KEY`、`ZHANLU_SECRET_KEY`、`ZHANLU_TOKEN`。 +1. 环境变量 `ZHANLU_ACCESS_KEY`、`ZHANLU_SECRET_KEY`、`ZHANLU_TOKEN` 或 `ZHANLU_API_KEY`。 2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件。 -登录页面保存后,运行中的服务会立即使用新凭据。 +登录页面保存后,运行中的服务会立即使用新凭据。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写凭据文件。 ## 安全说明 -- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token`,请不要提交到仓库。 +- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。 - 默认保存在当前执行目录的 `credentials.json`。 - 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。 - 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。 @@ -217,11 +225,9 @@ curl http://127.0.0.1:8080/v1/models ` ## 已知限制 -- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖。 +- 灵犀内网 SSO(`/auth/start`、`/auth/callback`)为备用接口,主流程是移动云手机号验证码登录。 - 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。 -- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。 -- UI 模型名 `glm4.7`、`minimax-m2.5` 会按插件逻辑映射为上游 `glm47`、`minimax-m25`。 -- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。 +- 模型网关对 OpenAI `stream:false` 请求由代理负责聚合流式响应。 ## 验证 diff --git a/go.mod b/go.mod index f438d87..8ce4ba6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module git.misaka.ren/M1saka/zhanlu_proxy -go 1.22 +go 1.25.0 + +require github.com/emmansun/gmsm v0.44.1 + +require golang.org/x/crypto v0.54.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7e4238 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/emmansun/gmsm v0.44.1 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4= +github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/auth/aes.go b/internal/auth/aes.go index ab4ad68..c8105f4 100644 --- a/internal/auth/aes.go +++ b/internal/auth/aes.go @@ -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 -} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index f9049de..61196b6 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -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 { diff --git a/internal/auth/sm2.go b/internal/auth/sm2.go new file mode 100644 index 0000000..f79fe01 --- /dev/null +++ b/internal/auth/sm2.go @@ -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") diff --git a/internal/auth/sm2_test.go b/internal/auth/sm2_test.go new file mode 100644 index 0000000..dd7b01f --- /dev/null +++ b/internal/auth/sm2_test.go @@ -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\":\"a@b.c\"}") + 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") + } +} diff --git a/internal/auth/sso.go b/internal/auth/sso.go index 40bf60f..8dfeb92 100644 --- a/internal/auth/sso.go +++ b/internal/auth/sso.go @@ -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 { diff --git a/internal/auth/sso_test.go b/internal/auth/sso_test.go new file mode 100644 index 0000000..52ddac2 --- /dev/null +++ b/internal/auth/sso_test.go @@ -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": "ssouser@example.com", "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 != "ssouser@example.com" || 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) +} diff --git a/internal/config/config.go b/internal/config/config.go index bb84b16..bfa154a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" diff --git a/internal/openai/types.go b/internal/openai/types.go index 88d9576..5bed020 100644 --- a/internal/openai/types.go +++ b/internal/openai/types.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 82a46a2..603b653 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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(`

湛卢代理登录

-

输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。

+

输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。

保存位置:
{{.CredentialsPath}}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..1e48ae8 --- /dev/null +++ b/internal/server/server_test.go @@ -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": "dev@example.com", "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 != "dev@example.com" { + 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: "dev@example.com", + } + 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-----` diff --git a/internal/sign/signer.go b/internal/sign/signer.go index a753822..9e0df40 100644 --- a/internal/sign/signer.go +++ b/internal/sign/signer.go @@ -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() + diff --git a/internal/sign/signer_test.go b/internal/sign/signer_test.go new file mode 100644 index 0000000..5466f36 --- /dev/null +++ b/internal/sign/signer_test.go @@ -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") + } +} diff --git a/internal/zhanlu/client.go b/internal/zhanlu/client.go index 94565ab..b413354 100644 --- a/internal/zhanlu/client.go +++ b/internal/zhanlu/client.go @@ -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 { diff --git a/internal/zhanlu/client_test.go b/internal/zhanlu/client_test.go new file mode 100644 index 0000000..7aa840c --- /dev/null +++ b/internal/zhanlu/client_test.go @@ -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": "dev@example.com", "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 != "dev@example.com" { + 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(), "a@b.c", "", "") + 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) +}