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:
@@ -7,3 +7,4 @@ extension/
|
||||
!.env.example
|
||||
tmp/
|
||||
temp/
|
||||
source/
|
||||
|
||||
@@ -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 <apiKey>` 请求 `{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` 请求由代理负责聚合流式响应。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
+8
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignSM2Authorization(t *testing.T) {
|
||||
const privHex = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
|
||||
sig, err := SignSM2Authorization(privHex, "1754460000:AbCdEfGh1234567890AbCdEfGh123456:{\"email\":\"[email protected]\"}")
|
||||
if err != nil {
|
||||
t.Fatalf("SignSM2Authorization: %v", err)
|
||||
}
|
||||
if len(sig) != 128 {
|
||||
t.Fatalf("signature length = %d, want 128 (r||s hex)", len(sig))
|
||||
}
|
||||
if _, err := hex.DecodeString(sig); err != nil {
|
||||
t.Fatalf("signature is not hex: %v", err)
|
||||
}
|
||||
// Deterministic inputs must produce a stable signature across calls only if
|
||||
// the nonce is fixed; sm-crypto randomizes k, so just check shape + parse.
|
||||
if strings.TrimSpace(sig) != sig {
|
||||
t.Fatalf("signature contains whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignSM2AuthorizationInvalidKey(t *testing.T) {
|
||||
if _, err := SignSM2Authorization("zz", "x"); err == nil {
|
||||
t.Fatal("expected error for invalid private key hex")
|
||||
}
|
||||
}
|
||||
+54
-38
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExchangeCodeAuthTokenFlow(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/acepilot/zhanlu/authToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
var in map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in["deputyAccountNumber"] != "dep-123" {
|
||||
t.Errorf("deputyAccountNumber = %q", in["deputyAccountNumber"])
|
||||
}
|
||||
// plaintext profile (DecryptCredentialOrRaw fallback)
|
||||
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{
|
||||
"email": "[email protected]", "organization": "org", "team": "team",
|
||||
}})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
profile, err := ExchangeCode(&http.Client{}, ts.URL+"/api/acepilot/zhanlu/authToken", "dep-123", "3jw7woww2rvhla6k")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode: %v", err)
|
||||
}
|
||||
if profile.Email != "[email protected]" || profile.Organization != "org" || profile.Team != "team" {
|
||||
t.Fatalf("profile = %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeMissingFields(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{}})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
if _, err := ExchangeCode(&http.Client{}, ts.URL+"/x", "dep", ""); err == nil {
|
||||
t.Fatal("expected error for empty profile")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
+52
-34
@@ -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"
|
||||
|
||||
@@ -28,21 +28,11 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
||||
model := map[string]string{
|
||||
"minimax-m2.5": "minimax-m25",
|
||||
"glm4.7": "glm47",
|
||||
}[r.Model]
|
||||
if model == "" {
|
||||
model = r.Model
|
||||
}
|
||||
m := map[string]any{
|
||||
"model": model,
|
||||
"model": r.Model,
|
||||
"messages": r.Messages,
|
||||
"temperature": 0,
|
||||
"stream": r.Stream,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
"max_tokens": 16000,
|
||||
"inputs": map[string]any{"aiDevQuestion": ""},
|
||||
}
|
||||
for k, v := range r.Extra {
|
||||
var anyValue any
|
||||
|
||||
+161
-66
@@ -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-----`
|
||||
@@ -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() +
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
)
|
||||
|
||||
const testPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
func TestBuildOpURLStructure(t *testing.T) {
|
||||
pub, err := auth.ParsePublicKey(testPublicKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 5, 2, 30, 0, 0, time.UTC)
|
||||
s := Signer{
|
||||
PublicKey: pub,
|
||||
Now: func() time.Time { return now },
|
||||
Nonce: func() string { return "fixed-nonce-1234567890" },
|
||||
}
|
||||
creds := auth.Credentials{AccessKey: "AK123", SecretKey: "SK456", Token: "TOK789"}
|
||||
u, err := s.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, "https://ecloud.10086.cn", "POST")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(u, "https://ecloud.10086.cn/api/acepilot/zhanlu/v1/login?") {
|
||||
t.Fatalf("url = %s", u)
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("AccessKey") != "AK123" {
|
||||
t.Errorf("AccessKey = %q", q.Get("AccessKey"))
|
||||
}
|
||||
if q.Get("SignatureMethod") != "HmacSHA1" {
|
||||
t.Errorf("SignatureMethod = %q", q.Get("SignatureMethod"))
|
||||
}
|
||||
if q.Get("SignatureVersion") != "V2.0" {
|
||||
t.Errorf("SignatureVersion = %q", q.Get("SignatureVersion"))
|
||||
}
|
||||
if q.Get("Version") != "2016-12-05" {
|
||||
t.Errorf("Version = %q", q.Get("Version"))
|
||||
}
|
||||
// Beijing time (UTC+8) formatted with Z suffix, matching the plugin's yE9.
|
||||
if q.Get("Timestamp") != "2026-08-05T10:30:00Z" {
|
||||
t.Errorf("Timestamp = %q, want 2026-08-05T10:30:00Z (Beijing time)", q.Get("Timestamp"))
|
||||
}
|
||||
if q.Get("Signature") == "" {
|
||||
t.Error("Signature is empty")
|
||||
}
|
||||
if _, err := hex.DecodeString(q.Get("Signature")); err != nil {
|
||||
t.Errorf("Signature is not hex: %v", err)
|
||||
}
|
||||
authz := q.Get("authorization")
|
||||
if authz == "" {
|
||||
t.Error("authorization is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpURLMissingCreds(t *testing.T) {
|
||||
s := Signer{}
|
||||
if _, err := s.BuildOpURL("/x", auth.Credentials{}, "http://x", "POST"); err == nil {
|
||||
t.Fatal("expected error for missing credentials")
|
||||
}
|
||||
}
|
||||
+226
-28
@@ -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