5 Commits
Author SHA1 Message Date
root dd5c560b64 Build Windows release binaries
build / build (push) Successful in 1m52s
2026-08-14 15:48:52 +08:00
m1saka 2e402934ea Polish login pages with glass-morphism design
build / build (push) Successful in 1m4s
Redesign the login and login-result pages within the existing dark glass
palette: layered star-dot/grid/gradient background, gradient hairline panel
borders, gradient headline text, primary/ghost button split, and tri-state
status dots (ok/err/busy). Fix zero vertical spacing inside the login form
(the form element had no gap between fields and the submit button).
2026-08-05 19:56:53 +08:00
m1saka df19ced538 Drop ZHANLU_MODELS and ZHANLU_DEFAULT_MODEL config
build / build (push) Successful in 1m5s
Model list is always fetched live from the gateway model-info endpoint, so the
static fallback list was dead config with stale IDs (GLM-4.7 vs zhanlu/glm-4.7).
Default to zhanlu/auto when the request omits model.
2026-08-05 15:06:38 +08:00
m1saka 7d8a5b6f74 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
2026-08-05 14:55:28 +08:00
m1saka c3af3caa5b Add admin login session flow
build / build (push) Successful in 58s
2026-07-09 00:22:27 +08:00
19 changed files with 1514 additions and 300 deletions
+11 -5
View File
@@ -58,8 +58,13 @@ jobs:
CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \ CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \ go build -trimpath -ldflags "${LDFLAGS}" \
-o "zhanlu-proxy-linux-${arch}" ./cmd/zhanlu-proxy -o "zhanlu-proxy-linux-${arch}" ./cmd/zhanlu-proxy
echo "building windows/${arch}"
CGO_ENABLED=0 GOOS=windows GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-o "zhanlu-proxy-windows-${arch}.exe" ./cmd/zhanlu-proxy
done done
ls -lh zhanlu-proxy-linux-* ls -lh zhanlu-proxy-linux-* zhanlu-proxy-windows-*.exe
- name: Publish release assets - name: Publish release assets
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
@@ -80,10 +85,11 @@ jobs:
echo "release id: ${RELEASE_ID}" echo "release id: ${RELEASE_ID}"
for arch in amd64 arm64; do for arch in amd64 arm64; do
f="zhanlu-proxy-linux-${arch}" for f in "zhanlu-proxy-linux-${arch}" "zhanlu-proxy-windows-${arch}.exe"; do
curl -fsSL -X POST -H "${AUTH}" \ curl -fsSL -X POST -H "${AUTH}" \
-F "attachment=@${f};filename=${f}" \ -F "attachment=@${f};filename=${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${f}" "${API}/releases/${RELEASE_ID}/assets?name=${f}"
done
done done
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -7,3 +7,4 @@ extension/
!.env.example !.env.example
tmp/ tmp/
temp/ temp/
source/
+38 -31
View File
@@ -1,13 +1,14 @@
# Zhanlu Proxy # Zhanlu Proxy
一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。 一个本地 Go 代理服务,用于读取湛卢v1.4.2插件凭据,按插件认证规则换取模型 API Key,并暴露 OpenAI 兼容接口。
当前实现包含: 当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。 - 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
- OpenAI 兼容接口:`/v1/models``/v1/chat/completions` - 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` - 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证
- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON - 模型 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` 结果续传。 - OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
## 运行 ## 运行
@@ -22,10 +23,10 @@ go run ./cmd/zhanlu-proxy
http://127.0.0.1:8080 http://127.0.0.1:8080
``` ```
打开登录页: 打开首页会自动跳转到管理登录页:
```text ```text
http://127.0.0.1:8080/login http://127.0.0.1:8080/
``` ```
## systemd 服务示例 ## systemd 服务示例
@@ -47,11 +48,10 @@ http://127.0.0.1:8080/login
```env ```env
ZHANLU_LISTEN_ADDR=:8080 ZHANLU_LISTEN_ADDR=:8080
ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json
ZHANLU_SERVER_BASE_URL=https://api-wuxi-1.cmecloud.cn:8443 ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
ZHANLU_MODELS=glm47,minimax-m25
ZHANLU_DEFAULT_MODEL=minimax-m25
ZHANLU_UPSTREAM_TIMEOUT=300s ZHANLU_UPSTREAM_TIMEOUT=300s
ZHANLU_LOGIN_PASSWORD=change-this-login-password
OPENAI_COMPAT_API_KEY=change-this-local-secret OPENAI_COMPAT_API_KEY=change-this-local-secret
``` ```
@@ -97,12 +97,16 @@ journalctl -u zhanlu-proxy -f
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作: 打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
如果设置了 `ZHANLU_LOGIN_PASSWORD``/login` 只负责管理密码登录。密码正确后服务会设置 HttpOnly 会话 Cookie,并跳转到 `/admin/login``/admin/login` 才是手机号验证码登录湛卢的页面,之后才能查看凭据状态、获取短信验证码、保存凭据或使用备用 SSO 管理接口。
- 生成 16 位一次性 `secret` - 生成 16 位一次性 `secret`
- 使用插件内置 RSA 公钥加密手机号和 `secret` - 使用插件内置 RSA 公钥加密手机号和 `secret`
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。 - 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
- 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode` - 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`
- 使用本次 `secret` AES 解密响应中的 `ak``sk``license`,得到 `AccessKey``SecretKey``Token` - 使用本次 `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 兼容接口自动使用。
默认保存到当前执行目录: 默认保存到当前执行目录:
@@ -116,13 +120,14 @@ credentials.json
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\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 ```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 兼容接口 ## OpenAI 兼容接口
@@ -145,7 +150,7 @@ curl http://127.0.0.1:8080/v1/models
```powershell ```powershell
curl http://127.0.0.1:8080/v1/chat/completions ` curl http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" ` -H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}' -d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":false}'
``` ```
流式: 流式:
@@ -153,7 +158,7 @@ curl http://127.0.0.1:8080/v1/chat/completions `
```powershell ```powershell
curl -N http://127.0.0.1:8080/v1/chat/completions ` curl -N http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" ` -H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}' -d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":true}'
``` ```
### 工具调用 ### 工具调用
@@ -176,21 +181,24 @@ curl http://127.0.0.1:8080/v1/models `
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 | | `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 |
| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL | | `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL` |
| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 | | `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_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
| `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey | | `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey |
| `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey | | `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token | | `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 | | `ZHANLU_API_KEY` | 空 | 直接从环境变量提供已换取的模型 API Key |
| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 | | `ZHANLU_SSO_BASE_URL` | `http://4c.hq.cmcc` | 灵犀内网 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 | | `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_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 | | `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 | | `ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY` | 插件内置 SM2 私钥 | 换取模型 API Key 时 `X-Auth-Signature` 使用的 SM2 私钥,通常不需要设置 |
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 | | `ZHANLU_PLUGIN_VERSION` | `1.4.2` | 请求 `plugin_version` |
| `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 | | `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 |
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 | | `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
| `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 |
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query | | `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key | | `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
@@ -198,25 +206,24 @@ 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 文件。 2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件。
登录页面保存后,运行中的服务会立即使用新凭据。 登录页面保存后,运行中的服务会立即使用新凭据。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写凭据文件。
## 安全说明 ## 安全说明
- `credentials.json` 包含明文 `AccessKey``SecretKey``Token`,请不要提交到仓库。 - `credentials.json` 包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json` - 默认保存在当前执行目录的 `credentials.json`
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature` - 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature`
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。 - `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
## 已知限制 ## 已知限制
- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖 - 灵犀内网 SSO`/auth/start``/auth/callback`)为备用接口,主流程是移动云手机号验证码登录
- 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。 - 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。
- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。 - 模型网关对 OpenAI `stream:false` 请求由代理负责聚合流式响应。
- UI 模型名 `glm4.7``minimax-m2.5` 会按插件逻辑映射为上游 `glm47``minimax-m25`
- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。
## 验证 ## 验证
+5 -1
View File
@@ -1,3 +1,7 @@
module git.misaka.ren/M1saka/zhanlu_proxy 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
+6
View File
@@ -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
View File
@@ -33,17 +33,16 @@ func DecryptCredential(ciphertextBase64, key string) (string, error) {
return string(plain), nil return string(plain), nil
} }
func EncryptCredential(plaintext, key string) (string, error) { // DecryptCredentialOrRaw mirrors the plugin's z4A: try AES-ECB decrypt with the
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize)) // given key, falling back to the raw value when the field is plaintext.
if err != nil { func DecryptCredentialOrRaw(value, key string) string {
return "", err if value == "" {
return ""
} }
plain := padPKCS7([]byte(plaintext), aes.BlockSize) if plain, err := DecryptCredential(value, key); err == nil && plain != "" {
out := make([]byte, len(plain)) return plain
for start := 0; start < len(plain); start += aes.BlockSize {
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
} }
return base64.StdEncoding.EncodeToString(out), nil return value
} }
func repeatKey(key string, size int) []byte { 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 return in[:len(in)-pad], nil
} }
func padPKCS7(in []byte, blockSize int) []byte {
pad := blockSize - len(in)%blockSize
out := make([]byte, len(in)+pad)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+25 -5
View File
@@ -10,14 +10,30 @@ import (
) )
type Credentials struct { type Credentials struct {
AccessKey string `json:"access_key"` AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key"`
Token string `json:"token"` Token string `json:"token"`
BaseURL string `json:"base_url,omitempty"` APIKey string `json:"api_key,omitempty"`
SavedAt time.Time `json:"saved_at"` 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 { func (c Credentials) Validate() error {
if strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" {
return nil
}
if strings.TrimSpace(c.AccessKey) == "" { if strings.TrimSpace(c.AccessKey) == "" {
return errors.New("access_key is required") return errors.New("access_key is required")
} }
@@ -30,6 +46,10 @@ func (c Credentials) Validate() error {
return nil return nil
} }
func (c Credentials) HasAPIKey() bool {
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
}
func LoadCredentials(path string) (Credentials, error) { func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
+36
View File
@@ -0,0 +1,36 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
)
// SignSM2Authorization signs `message` with the SM2 private key in hex form
// (mirroring the Zhanlu plugin: SM3 digest signed with hash:false, der:false,
// output as 64-byte r||s hex).
func SignSM2Authorization(privateKeyHex, message string) (string, error) {
keyHex := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
return "", err
}
priv, err := sm2.NewPrivateKey(keyBytes)
if err != nil {
return "", err
}
digest := sm3.Sum([]byte(message))
r, s, err := sm2.Sign(rand.Reader, &priv.PrivateKey, digest[:])
if err != nil {
return "", err
}
rb := r.FillBytes(make([]byte, 32))
sb := s.FillBytes(make([]byte, 32))
return hex.EncodeToString(append(rb, sb...)), nil
}
var errEmptySM2Key = errors.New("SM2 private key is required")
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"encoding/hex"
"strings"
"testing"
)
func TestSignSM2Authorization(t *testing.T) {
const privHex = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
sig, err := SignSM2Authorization(privHex, "1754460000:AbCdEfGh1234567890AbCdEfGh123456:{\"email\":\"[email protected]\"}")
if err != nil {
t.Fatalf("SignSM2Authorization: %v", err)
}
if len(sig) != 128 {
t.Fatalf("signature length = %d, want 128 (r||s hex)", len(sig))
}
if _, err := hex.DecodeString(sig); err != nil {
t.Fatalf("signature is not hex: %v", err)
}
// Deterministic inputs must produce a stable signature across calls only if
// the nonce is fixed; sm-crypto randomizes k, so just check shape + parse.
if strings.TrimSpace(sig) != sig {
t.Fatalf("signature contains whitespace")
}
}
func TestSignSM2AuthorizationInvalidKey(t *testing.T) {
if _, err := SignSM2Authorization("zz", "x"); err == nil {
t.Fatal("expected error for invalid private key hex")
}
}
+54 -38
View File
@@ -10,74 +10,90 @@ import (
"time" "time"
) )
type ExchangeResponse struct { // ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
ErrorCode string `json:"errorCode"` // gateway authToken endpoint (POST /api/acepilot/zhanlu/authToken). The
ErrorMsg string `json:"errorMsg"` // profile fields may be AES-ECB encrypted with the token decrypt key; each
Message string `json:"message"` // field falls back to the raw value when decryption fails.
Body map[string]any `json:"body"` func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Profile, error) {
}
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
if strings.TrimSpace(endpoint) == "" { if strings.TrimSpace(endpoint) == "" {
return Credentials{}, errors.New("exchange endpoint is required") return Profile{}, errors.New("exchange endpoint is required")
} }
if strings.TrimSpace(code) == "" { if strings.TrimSpace(code) == "" {
return Credentials{}, errors.New("code is required") return Profile{}, errors.New("code is required")
}
if strings.TrimSpace(decryptKey) == "" {
return Credentials{}, errors.New("decrypt key is required")
} }
if client == nil { 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)) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
return Credentials{}, err return Profile{}, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return Credentials{}, err return Profile{}, err
} }
defer resp.Body.Close() 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 var exchange ExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil { 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) 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) profile := Profile{}
if err != nil { for _, m := range []map[string]any{exchange.Body, exchange.Result, exchange.Data} {
return Credentials{}, err 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) return Profile{}, errors.New("exchange response body missing profile fields")
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
} }
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) { func decryptProfileField(m map[string]any, key, decryptKey string) string {
v, ok := body[key] v, ok := m[key]
if !ok { if !ok {
return "", fmt.Errorf("response body missing %s", key) return ""
} }
s, ok := v.(string) s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" { if !ok {
return "", fmt.Errorf("response body %s is not a string", key) 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 { func firstNonEmpty(values ...string) string {
+54
View File
@@ -0,0 +1,54 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExchangeCodeAuthTokenFlow(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/authToken", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s", r.Method)
}
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
if in["deputyAccountNumber"] != "dep-123" {
t.Errorf("deputyAccountNumber = %q", in["deputyAccountNumber"])
}
// plaintext profile (DecryptCredentialOrRaw fallback)
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "org", "team": "team",
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
profile, err := ExchangeCode(&http.Client{}, ts.URL+"/api/acepilot/zhanlu/authToken", "dep-123", "3jw7woww2rvhla6k")
if err != nil {
t.Fatalf("ExchangeCode: %v", err)
}
if profile.Email != "[email protected]" || profile.Organization != "org" || profile.Team != "team" {
t.Fatalf("profile = %+v", profile)
}
}
func TestExchangeCodeMissingFields(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) {
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
if _, err := ExchangeCode(&http.Client{}, ts.URL+"/x", "dep", ""); err == nil {
t.Fatal("expected error for empty profile")
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
+48 -44
View File
@@ -10,48 +10,53 @@ import (
) )
type Config struct { type Config struct {
ListenAddr string ListenAddr string
ServerBaseURL string MobileLoginBaseURL string
UpstreamPath string MobileModelBaseURL string
CredentialsPath string UpstreamPath string
SSOExchangeURL string CredentialsPath string
SSOBaseURL string SSOExchangeURL string
TokenDecryptKey string SSOBaseURL string
PublicKeyPEM string TokenDecryptKey string
PhonePublicKeyPEM string PublicKeyPEM string
Models []string PhonePublicKeyPEM string
DefaultModel string SM2PrivateKey string
OpenAIAPIKey string OpenAIAPIKey string
UpstreamTimeout time.Duration LoginPassword string
StreamIdleTimout time.Duration PluginVersion string
Debug bool UpstreamTimeout time.Duration
Credentials auth.Credentials StreamIdleTimout time.Duration
Debug bool
Credentials auth.Credentials
} }
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"), ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"), MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"), MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()), UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"), CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"), SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"), SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM), TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM), PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"), PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"), SM2PrivateKey: getenv("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY", defaultSM2PrivateKey),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second), OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second), LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"), 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.Credentials = auth.Credentials{ cfg.Credentials = auth.Credentials{
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"), AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"), SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
Token: os.Getenv("ZHANLU_TOKEN"), 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 return cfg, nil
} }
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil { if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
@@ -67,18 +72,6 @@ func getenv(key, fallback string) string {
return fallback return fallback
} }
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func durationEnv(key string, fallback time.Duration) time.Duration { func durationEnv(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
@@ -91,6 +84,15 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d return d
} }
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func defaultCredentialsPath() string { func defaultCredentialsPath() string {
return filepath.Join(".", "credentials.json") return filepath.Join(".", "credentials.json")
} }
@@ -102,3 +104,5 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUM
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY----- const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
-----END PUBLIC KEY-----` -----END PUBLIC KEY-----`
const defaultSM2PrivateKey = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
+1 -11
View File
@@ -28,21 +28,11 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
} }
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) { 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{ m := map[string]any{
"model": model, "model": r.Model,
"messages": r.Messages, "messages": r.Messages,
"temperature": 0,
"stream": r.Stream, "stream": r.Stream,
"stream_options": map[string]any{"include_usage": true}, "stream_options": map[string]any{"include_usage": true},
"max_tokens": 16000,
"inputs": map[string]any{"aiDevQuestion": ""},
} }
for k, v := range r.Extra { for k, v := range r.Extra {
var anyValue any var anyValue any
+545 -117
View File
@@ -2,6 +2,10 @@ package server
import ( import (
"bufio" "bufio"
"context"
crand "crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -21,26 +25,34 @@ import (
) )
type Server struct { type Server struct {
cfg config.Config cfg config.Config
mux *http.ServeMux mux *http.ServeMux
loginSession string
} }
func New(cfg config.Config) http.Handler { func New(cfg config.Config) http.Handler {
s := &Server{cfg: cfg, mux: http.NewServeMux()} s := &Server{cfg: cfg, mux: http.NewServeMux()}
if cfg.LoginPassword != "" {
s.loginSession = randomSessionToken()
}
s.routes() s.routes()
return s.mux return s.mux
} }
func (s *Server) routes() { func (s *Server) routes() {
s.mux.HandleFunc("GET /", s.index)
s.mux.HandleFunc("GET /healthz", s.healthz) s.mux.HandleFunc("GET /healthz", s.healthz)
s.mux.HandleFunc("GET /login", s.loginPage) s.mux.HandleFunc("GET /login", s.loginPage)
s.mux.HandleFunc("GET /auth/start", s.startSSO) s.mux.HandleFunc("GET /admin/login", s.adminLoginPage)
s.mux.HandleFunc("GET /auth/callback", s.ssoCallback) s.mux.HandleFunc("POST /api/login", s.passwordLogin)
s.mux.HandleFunc("POST /api/auth/code", s.requestPhoneCode) s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
s.mux.HandleFunc("POST /api/auth/login", s.loginWithPhoneCode) s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
s.mux.HandleFunc("GET /api/credentials", s.getCredentials) s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback))
s.mux.HandleFunc("POST /api/credentials", s.saveCredentials) s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode))
s.mux.HandleFunc("POST /api/sso/exchange", s.exchangeSSOCode) s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode))
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
} }
@@ -49,6 +61,14 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc { func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.OpenAIAPIKey != "" { if s.cfg.OpenAIAPIKey != "" {
@@ -63,8 +83,48 @@ func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
} }
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if s.hasLoginSession(r) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
if s.cfg.LoginPassword == "" {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL}) _ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
}
func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
if !s.validLoginPassword(in.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "登录密码无效"})
return
}
if s.cfg.LoginPassword != "" {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) { func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
@@ -89,12 +149,16 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录") s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
return 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 { if err != nil {
s.renderLoginResult(w, false, err.Error()) s.renderLoginResult(w, false, err.Error())
return return
} }
creds.BaseURL = s.cfg.ServerBaseURL
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil { if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
s.renderLoginResult(w, false, err.Error()) s.renderLoginResult(w, false, err.Error())
return return
@@ -103,6 +167,26 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
s.renderLoginResult(w, true, "凭据已保存,可以关闭此页面并使用 OpenAI 兼容接口") 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) { func (s *Server) renderLoginResult(w http.ResponseWriter, success bool, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -159,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()}) writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return 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 var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "secret": secretCipher}, &out); err != nil { 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()}) writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
@@ -199,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()}) writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return 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 var out phoneAPIResponse
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "code": code}, &out); err != nil { 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()}) writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
@@ -214,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()}) writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return 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 { if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return return
@@ -223,6 +312,66 @@ 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)}) 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
}
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.LoginPassword)) == 1
}
func (s *Server) withLoginSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.hasLoginSession(r) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "请先登录管理页面"})
return
}
next(w, r)
}
}
func (s *Server) hasLoginSession(r *http.Request) bool {
if s.cfg.LoginPassword == "" {
return true
}
c, err := r.Cookie(loginSessionCookieName)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(s.loginSession)) == 1
}
func randomSessionToken() string {
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return randomRequestID()
}
return hex.EncodeToString(b)
}
const loginSessionCookieName = "zhanlu_proxy_session"
type phoneAPIResponse struct { type phoneAPIResponse struct {
State string `json:"state"` State string `json:"state"`
ErrorMessage string `json:"errorMessage"` ErrorMessage string `json:"errorMessage"`
@@ -244,10 +393,10 @@ func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *p
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "vscode") req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", "2.8.0") req.Header.Set("plugin_version", s.cfg.PluginVersion)
req.Header.Set("request", randomRequestID()) req.Header.Set("request", randomRequestID())
resp, err := (&http.Client{Timeout: s.cfg.UpstreamTimeout}).Do(req) resp, err := s.upstreamHTTPClient().Do(req)
if err != nil { if err != nil {
return err return err
} }
@@ -265,17 +414,12 @@ func decryptPhoneCredentials(body struct {
SK string `json:"sk"` SK string `json:"sk"`
License string `json:"license"` License string `json:"license"`
}, secret string) (auth.Credentials, error) { }, secret string) (auth.Credentials, error) {
ak, err := auth.DecryptCredential(strings.TrimSpace(body.AK), secret) // z4A semantics: try AES-ECB decrypt with secret, fall back to plaintext.
if err != nil { ak := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.AK), secret)
return auth.Credentials{}, fmt.Errorf("decrypt access key: %w", err) sk := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.SK), secret)
} token := auth.DecryptCredentialOrRaw(strings.TrimSpace(body.License), secret)
sk, err := auth.DecryptCredential(strings.TrimSpace(body.SK), secret) if ak == "" || sk == "" || token == "" {
if err != nil { return auth.Credentials{}, fmt.Errorf("decrypt phone credentials: missing ak/sk/license")
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)
} }
return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil
} }
@@ -313,11 +457,13 @@ func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"configured": true, "configured": true,
"path": s.cfg.CredentialsPath, "path": s.cfg.CredentialsPath,
"access_key": mask(c.AccessKey), "access_key": mask(c.AccessKey),
"base_url": c.BaseURL, "has_api_key": c.APIKey != "",
"saved_at": c.SavedAt, "model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
"email": c.Email,
"saved_at": c.SavedAt,
}) })
} }
@@ -327,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()}) writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return 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 { if err := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return return
@@ -348,12 +503,19 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
} }
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL) endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey) 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 { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return 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 { if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return return
@@ -363,8 +525,16 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) models(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)) modelIDs := []string{}
for _, model := range 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"}) 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}) writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
@@ -382,11 +552,9 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Model == "" { if req.Model == "" {
req.Model = s.cfg.DefaultModel req.Model = "zhanlu/auto"
} }
clientWantsStream := req.Stream 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 req.Stream = true
body, err := req.MarshalForUpstream() body, err := req.MarshalForUpstream()
if err != nil { if err != nil {
@@ -394,13 +562,22 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed") writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return return
} }
client := zhanlu.NewClient(s.cfg.ServerBaseURL, s.cfg.UpstreamPath, creds, signer, s.cfg.UpstreamTimeout) resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
resp, err := client.ChatCompletions(r.Context(), body)
if err != nil { if err != nil {
msg := "zhanlu upstream request failed" msg := "zhanlu upstream request failed"
if s.cfg.Debug { if s.cfg.Debug {
@@ -420,34 +597,30 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
return return
} }
if clientWantsStream { if clientWantsStream {
s.proxyDecryptedStream(w, resp, creds.Token) s.proxyStream(w, resp)
return 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("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher) flusher, _ := w.(http.Flusher)
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error { _, err := io.Copy(w, resp.Body)
_, err := fmt.Fprintf(w, "data: %s\n\n", chunk) if flusher != nil {
if flusher != nil { flusher.Flush()
flusher.Flush() }
}
return err
})
if err != nil { if err != nil {
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}}) 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) _, _ = 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 content, reasoning, id string
var usage any var usage any
finishReason := "stop" finishReason := "stop"
@@ -460,7 +633,7 @@ func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Resp
} `json:"function"` } `json:"function"`
} }
toolCalls := map[int]*toolCall{} toolCalls := map[int]*toolCall{}
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error { err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct { var event struct {
ID string `json:"id"` ID string `json:"id"`
Choices []struct { Choices []struct {
@@ -548,33 +721,28 @@ func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Resp
writeJSON(w, http.StatusOK, result) 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 := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
if line == "" { if line == "" || !strings.HasPrefix(line, "data:") {
continue continue
} }
if !strings.HasPrefix(line, "data:") { payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
var upstreamError map[string]any if payload == "" || payload == "[DONE]" {
if json.Unmarshal([]byte(line), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
continue continue
} }
ciphertext := strings.TrimSpace(strings.TrimPrefix(line, "data:")) var upstreamError map[string]any
if ciphertext == "" || ciphertext == "[DONE]" { if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
continue return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
} }
plain, err := auth.DecryptCredential(ciphertext, token) if !json.Valid([]byte(payload)) {
if err != nil { return errors.New("zhanlu stream contained invalid JSON")
return fmt.Errorf("decrypt zhanlu stream: %w", err)
} }
if !json.Valid([]byte(plain)) { if err := fn([]byte(payload)); err != nil {
return errors.New("zhanlu stream contained invalid decrypted JSON")
}
if err := fn([]byte(plain)); err != nil {
return err return err
} }
} }
@@ -582,14 +750,38 @@ func forEachDecryptedChunk(r io.Reader, token string, fn func([]byte) error) err
} }
func (s *Server) currentCredentials() (auth.Credentials, error) { 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 return s.cfg.Credentials, nil
} }
c, err := auth.LoadCredentials(s.cfg.CredentialsPath) c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
if err != nil { if err != nil {
return auth.Credentials{}, err 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) { func (s *Server) signer() (sign.Signer, error) {
@@ -657,38 +849,208 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>湛卢代理登录</title> <title>湛卢代理登录</title>
<style> <style>
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } :root {
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #dde7ff, transparent 34rem), linear-gradient(135deg, #101828, #1f2937); color: #e5e7eb; } color-scheme: dark;
main { width: min(760px, calc(100vw - 32px)); display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: stretch; } font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.hero, form { border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); } --bg: #0b1220;
.hero { padding: 30px; display: flex; flex-direction: column; justify-content: space-between; } --panel: rgba(15, 23, 42, .82);
h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.04em; } --line: rgba(255, 255, 255, .12);
p { color: #b6c2d9; line-height: 1.7; } --line-strong: rgba(255, 255, 255, .2);
code { color: #bfdbfe; word-break: break-all; } --accent-1: #3b82f6;
.login-card { padding: 28px; display: grid; gap: 16px; border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); } --accent-2: #8b5cf6;
label { display: grid; gap: 8px; font-size: 14px; color: #cbd5e1; } --ink: #f8fafc;
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(148,163,184,.35); border-radius: 14px; padding: 12px 14px; background: rgba(2,6,23,.55); color: #f8fafc; outline: none; font: inherit; } --body: #b6c2d9;
input:focus, textarea:focus { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(96,165,250,.16); } --muted: #8ba0b8;
--ok: #34d399;
--err: #f87171;
--radius-lg: 24px;
--radius-md: 14px;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
display: grid; place-items: center;
padding: 24px 16px;
color: var(--ink);
background:
radial-gradient(1.5px 1.5px at 12% 20%, rgba(255,255,255,.22), transparent 55%),
radial-gradient(1.5px 1.5px at 78% 14%, rgba(255,255,255,.18), transparent 55%),
radial-gradient(1.5px 1.5px at 88% 68%, rgba(255,255,255,.16), transparent 55%),
radial-gradient(1.5px 1.5px at 26% 82%, rgba(255,255,255,.14), transparent 55%),
radial-gradient(1.5px 1.5px at 58% 92%, rgba(255,255,255,.12), transparent 55%),
linear-gradient(rgba(255,255,255,.022) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.022) 1px, transparent 1px),
radial-gradient(60rem 42rem at 12% -8%, rgba(59,130,246,.16), transparent 60%),
radial-gradient(50rem 36rem at 105% 110%, rgba(139,92,246,.14), transparent 60%),
linear-gradient(160deg, #0b1220 0%, #111a2e 55%, #0e1626 100%);
background-size: auto, auto, auto, auto, auto, 44px 44px, 44px 44px, auto, auto, auto;
animation: fade-in .5s ease both;
}
@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
main {
width: min(820px, 100%);
display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: stretch;
}
.panel {
position: relative;
border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,.2), rgba(255,255,255,.05) 38%, rgba(255,255,255,.09));
box-shadow: 0 24px 80px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.08);
}
.panel-inner {
height: 100%;
border-radius: calc(var(--radius-lg) - 1px);
background: var(--panel);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.panel::before {
content: "";
position: absolute; inset: 0; border-radius: var(--radius-lg);
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,.14), transparent 30%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.hero { padding: 34px 30px; display: flex; flex-direction: column; justify-content: space-between; gap: 32px; }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
color: var(--muted);
}
.eyebrow::before {
content: ""; width: 22px; height: 1.5px;
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
}
h1 {
margin: 14px 0 0;
font-size: clamp(30px, 4.4vw, 46px);
font-weight: 800; letter-spacing: -0.035em; line-height: 1.08;
background: linear-gradient(92deg, #f8fafc 20%, #bfdbfe 62%, #c4b5fd 100%);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; color: transparent;
}
h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
p { margin: 0; color: var(--body); line-height: 1.7; font-size: 15px; }
.hero p { max-width: 34ch; }
code {
font-family: "SF Mono", ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12.5px; color: #bfdbfe; word-break: break-all;
}
.cred-path { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: rgba(2,6,23,.5); }
.cred-path .label { display: block; font-size: 11.5px; letter-spacing: .08em; color: var(--muted); margin-bottom: 6px; }
.login-card { padding: 30px 28px; display: grid; gap: 16px; align-content: start; }
.login-card form { display: grid; gap: 18px; }
.login-card form .btn-primary { width: 100%; margin-top: 2px; }
.login-card p { font-size: 13.5px; }
label { display: grid; gap: 8px; font-size: 13.5px; font-weight: 500; color: #cbd5e1; }
input, textarea {
width: 100%;
border: 1px solid rgba(148,163,184,.3);
border-radius: var(--radius-md);
padding: 13px 14px;
background: rgba(2,6,23,.55);
color: var(--ink);
outline: none; font: inherit;
transition: border-color .18s ease, box-shadow .18s ease, background .18s ease;
}
input::placeholder { color: #64748b; }
input:hover { border-color: rgba(148,163,184,.5); }
input:focus, textarea:focus {
border-color: var(--accent-1);
box-shadow: 0 0 0 4px rgba(96,165,250,.16);
background: rgba(2,6,23,.7);
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.login-button { display: block; text-align: center; text-decoration: none; border: 0; border-radius: 14px; padding: 14px 16px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; font-weight: 700; cursor: pointer; font: inherit; } .btn {
.login-button:hover { filter: brightness(1.08); } display: inline-flex; align-items: center; justify-content: center; gap: 8px;
.status { min-height: 22px; color: #93c5fd; } border: 0; border-radius: var(--radius-md);
.muted { font-size: 13px; color: #94a3b8; } padding: 14px 16px;
@media (max-width: 760px) { main { grid-template-columns: 1fr; padding: 18px 0; } .row { grid-template-columns: 1fr; } } font: inherit; font-weight: 700; font-size: 15px;
cursor: pointer; text-decoration: none; color: #fff;
transition: filter .15s ease, transform .06s ease, box-shadow .15s ease, background .15s ease, border-color .15s ease;
}
.btn:focus-visible { outline: 2px solid var(--accent-1); outline-offset: 2px; }
.btn-primary {
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
box-shadow: 0 10px 24px -10px rgba(99,102,241,.55);
}
.btn-primary:hover { filter: brightness(1.1); box-shadow: 0 12px 28px -10px rgba(99,102,241,.7); }
.btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled { filter: saturate(.5) brightness(.8); cursor: not-allowed; box-shadow: none; }
.btn-ghost {
background: transparent;
border: 1px solid rgba(148,163,184,.35);
color: #bfdbfe; font-weight: 600;
}
.btn-ghost:hover { border-color: var(--accent-1); color: #dbeafe; background: rgba(96,165,250,.08); }
.btn-ghost:active { transform: translateY(1px); }
.btn-ghost:disabled { opacity: .55; cursor: not-allowed; }
.status {
min-height: 22px;
font-size: 13.5px; line-height: 1.6;
color: var(--muted);
display: flex; align-items: flex-start; gap: 7px;
}
.status::before {
content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%;
margin-top: 6px;
background: currentColor;
box-shadow: 0 0 0 3px transparent;
}
.status[data-state="ok"] { color: var(--ok); }
.status[data-state="err"] { color: var(--err); }
.status[data-state="busy"] { color: #93c5fd; }
.status[data-state="busy"]::before { animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.muted { font-size: 12.5px; color: var(--muted); line-height: 1.7; }
.muted a, .link { color: #93c5fd; text-decoration: none; }
.link:hover { text-decoration: underline; }
.logout { border: 0; background: none; padding: 0; font: inherit; font-size: 12.5px; color: #93c5fd; cursor: pointer; }
.logout:hover { text-decoration: underline; }
.spacer { flex: 1; }
@media (max-width: 760px) {
body { place-items: start center; padding-top: 20px; }
main { grid-template-columns: 1fr; gap: 18px; }
.row { grid-template-columns: 1fr; }
.hero { padding: 28px 24px; }
.login-card { padding: 26px 22px; }
}
@media (prefers-reduced-motion: reduce) {
body { animation: none; }
.status[data-state="busy"]::before { animation: none; }
}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<section class="hero"> <section class="panel hero">
<div> <div>
<div class="eyebrow">Zhanlu Proxy</div>
<h1>湛卢代理登录</h1> <h1>湛卢代理登录</h1>
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p> <p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
</div>
<div class="cred-path">
<span class="label">凭据保存位置</span>
<code>{{.CredentialsPath}}</code>
</div> </div>
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
</section> </section>
<section class="login-card"> <section class="panel login-card">
{{if not .AdminMode}}
<h2>管理登录</h2>
<p>请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入登录管理</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
{{else}}
<h2>手机号验证码登录</h2> <h2>手机号验证码登录</h2>
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p> <p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
<form id="phone-form"> <form id="phone-form">
@@ -696,25 +1058,29 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<label>验证码 <label>验证码
<div class="row"> <div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required> <input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="login-button" id="code-button" type="button">获取验证码</button> <button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div> </div>
</label> </label>
<button class="login-button" type="submit">登录并保存凭据</button> <button class="btn btn-primary" type="submit">登录并保存凭据</button>
</form> </form>
<div class="status" id="status">正在检查登录状态...</div> <div class="status" id="status" data-state="busy">正在检查登录状态...</div>
<div class="muted">凭据保存到 JSON;验证码本身不会保存。</div> <div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button class="logout" id="logout-button" type="button">退出管理登录</button>{{end}}</div>
{{end}}
</section> </section>
</main> </main>
<script> <script>
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const form = document.getElementById('phone-form'); const form = document.getElementById('phone-form');
const passwordForm = document.getElementById('password-form');
const codeButton = document.getElementById('code-button'); const codeButton = document.getElementById('code-button');
const logoutButton = document.getElementById('logout-button');
let secret = ''; let secret = '';
let countdown = 0; let countdown = 0;
let countdownTimer = null; let countdownTimer = null;
function setStatus(text) { function setStatus(text, state) {
statusEl.textContent = text; statusEl.textContent = text;
statusEl.dataset.state = state || '';
} }
function startCountdown() { function startCountdown() {
@@ -733,18 +1099,51 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
}, 1000); }, 1000);
} }
fetch('/api/credentials').then(r => r.json()).then(data => { if (passwordForm) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录'; passwordForm.addEventListener('submit', async (event) => {
}); event.preventDefault();
const password = passwordForm.password.value;
if (!password) {
setStatus('请输入登录密码', 'err');
return;
}
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败', 'err');
return;
}
window.location.href = '/admin/login';
});
}
codeButton.addEventListener('click', async () => { if (logoutButton) {
logoutButton.addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
}
if (form) {
fetch('/api/credentials').then(r => r.json()).then(data => {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
codeButton && codeButton.addEventListener('click', async () => {
const telephone = form.telephone.value.trim(); const telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { if (!/^1[3-9]\d{9}$/.test(telephone)) {
setStatus('请输入有效的 11 位手机号'); setStatus('请输入有效的 11 位手机号', 'err');
return; return;
} }
codeButton.disabled = true; codeButton.disabled = true;
setStatus('正在发送验证码...'); setStatus('正在发送验证码...', 'busy');
const res = await fetch('/api/auth/code', { const res = await fetch('/api/auth/code', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -753,23 +1152,23 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
codeButton.disabled = false; codeButton.disabled = false;
setStatus(data.error || '验证码发送失败'); setStatus(data.error || '验证码发送失败', 'err');
return; return;
} }
secret = data.secret; secret = data.secret;
setStatus('验证码已发送'); setStatus('验证码已发送', 'ok');
startCountdown(); startCountdown();
}); });
form.addEventListener('submit', async (event) => { form && form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const telephone = form.telephone.value.trim(); const telephone = form.telephone.value.trim();
const code = form.code.value.trim(); const code = form.code.value.trim();
if (!secret) { if (!secret) {
setStatus('请先获取验证码'); setStatus('请先获取验证码', 'err');
return; return;
} }
setStatus('正在登录并保存凭据...'); setStatus('正在登录并保存凭据...', 'busy');
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -777,10 +1176,10 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败'); setStatus(data.error || '登录失败', 'err');
return; return;
} }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + 'JSON' + (data.path || '')); setStatus('登录成功,已保存凭据:' + (data.access_key || '') + 'JSON' + (data.path || ''), 'ok');
}); });
</script> </script>
</body> </body>
@@ -788,5 +1187,34 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html> var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>湛卢登录结果</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font-family:system-ui}.card{max-width:560px;margin:24px;padding:32px;border:1px solid #334155;border-radius:22px;background:#1e293b;text-align:center}a{color:#93c5fd}</style></head> <meta name="color-scheme" content="dark">
<body><main class="card">{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}<p>{{.Message}}</p><a href="/login">返回登录页</a></main></body></html>`)) <title>湛卢登录结果</title><style>
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--ink:#f8fafc;--body:#b6c2d9;--ok:#34d399;--err:#f87171;--accent-1:#3b82f6;--accent-2:#8b5cf6}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;color:var(--ink);
background:
radial-gradient(60rem 42rem at 12% -8%,rgba(59,130,246,.16),transparent 60%),
radial-gradient(50rem 36rem at 105% 110%,rgba(139,92,246,.14),transparent 60%),
linear-gradient(160deg,#0b1220 0%,#111a2e 55%,#0e1626 100%);
animation:fade-in .5s ease both}
@keyframes fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media(prefers-reduced-motion:reduce){body{animation:none}}
.card{width:min(480px,100%);padding:44px 36px;border-radius:24px;text-align:center;position:relative;background:rgba(15,23,42,.82);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);box-shadow:0 24px 80px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12)}
.mark{width:64px;height:64px;margin:0 auto 22px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:30px;height:30px}
.mark.ok{background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.35)}
.mark.err{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.35)}
h1{margin:0;font-size:24px;font-weight:800;letter-spacing:-.02em}
p{margin:14px 0 26px;color:var(--body);line-height:1.7;font-size:14.5px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:14px;padding:13px 26px;font:inherit;font-weight:700;font-size:14.5px;text-decoration:none;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent-1),var(--accent-2));box-shadow:0 10px 24px -10px rgba(99,102,241,.55);transition:filter .15s ease}
.btn:hover{filter:brightness(1.1)}
.btn:focus-visible{outline:2px solid var(--accent-1);outline-offset:2px}
</style></head>
<body><main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/login">返回登录页</a>
</main></body></html>`))
+204
View File
@@ -0,0 +1,204 @@
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,
PluginVersion: "1.4.2",
}
h := New(cfg)
proxy := httptest.NewServer(h)
return upstream, proxy, credsFile
}
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
func TestPhoneLoginAndChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// 1. request phone code
resp, err := http.Post(proxy.URL+"/api/auth/code", "application/json", strings.NewReader(`{"telephone":"13800000000"}`))
if err != nil {
t.Fatal(err)
}
var codeResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&codeResp)
resp.Body.Close()
secret, _ := codeResp["secret"].(string)
// 2. login with phone code (server RSA-encrypts the telephone itself)
loginBody, _ := json.Marshal(map[string]string{"telephone": "13800000000", "code": "123456", "secret": secret})
resp, err = http.Post(proxy.URL+"/api/auth/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
t.Fatal(err)
}
var loginResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&loginResp)
resp.Body.Close()
if !okValue(loginResp) {
t.Fatalf("login failed: %v", loginResp)
}
// 3. credentials file should contain the provisioned api key
creds, err := auth.LoadCredentials(credsFile)
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "sk-test-456" {
t.Fatalf("apiKey = %q", creds.APIKey)
}
if creds.Email != "[email protected]" {
t.Fatalf("email = %q", creds.Email)
}
// 4. non-streaming chat completion
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var chatResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
t.Fatal(err)
}
choices, _ := chatResp["choices"].([]any)
if len(choices) != 1 {
t.Fatalf("chat choices = %v", chatResp)
}
msg, _ := choices[0].(map[string]any)["message"].(map[string]any)
if msg["content"] != "hello" {
t.Fatalf("chat content = %v", msg)
}
// 5. models endpoint should prefer gateway model info
resp, err = http.Get(proxy.URL + "/v1/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var modelsResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&modelsResp)
items, _ := modelsResp["data"].([]any)
if len(items) != 2 {
t.Fatalf("models = %v", modelsResp)
}
}
func TestStreamingChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// Seed credentials directly with the api key
creds := auth.Credentials{
AccessKey: "AK",
SecretKey: "SK",
Token: "TOKEN",
APIKey: "sk-test-456",
ModelBaseURL: upstream.URL,
Email: "[email protected]",
}
if err := auth.SaveCredentials(credsFile, creds); err != nil {
t.Fatal(err)
}
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
if !strings.Contains(body, "data: ") || !strings.Contains(body, "hello") || !strings.Contains(body, "[DONE]") {
t.Fatalf("stream body: %s", body)
}
}
func okValue(m map[string]any) bool {
ok, _ := m["ok"].(bool)
return ok
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
var _ = context.Background
const defaultTestPublicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+3 -1
View File
@@ -46,7 +46,9 @@ func (s Signer) BuildOpURL(path string, creds auth.Credentials, baseURL string,
if err != nil { if err != nil {
return "", err 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 + query := "AccessKey=" + creds.AccessKey +
"&SignatureMethod=HmacSHA1" + "&SignatureMethod=HmacSHA1" +
"&SignatureNonce=" + nonce() + "&SignatureNonce=" + nonce() +
+74
View File
@@ -0,0 +1,74 @@
package sign
import (
"encoding/hex"
"net/url"
"strings"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
const testPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
func TestBuildOpURLStructure(t *testing.T) {
pub, err := auth.ParsePublicKey(testPublicKeyPEM)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 5, 2, 30, 0, 0, time.UTC)
s := Signer{
PublicKey: pub,
Now: func() time.Time { return now },
Nonce: func() string { return "fixed-nonce-1234567890" },
}
creds := auth.Credentials{AccessKey: "AK123", SecretKey: "SK456", Token: "TOK789"}
u, err := s.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, "https://ecloud.10086.cn", "POST")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(u, "https://ecloud.10086.cn/api/acepilot/zhanlu/v1/login?") {
t.Fatalf("url = %s", u)
}
parsed, err := url.Parse(u)
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if q.Get("AccessKey") != "AK123" {
t.Errorf("AccessKey = %q", q.Get("AccessKey"))
}
if q.Get("SignatureMethod") != "HmacSHA1" {
t.Errorf("SignatureMethod = %q", q.Get("SignatureMethod"))
}
if q.Get("SignatureVersion") != "V2.0" {
t.Errorf("SignatureVersion = %q", q.Get("SignatureVersion"))
}
if q.Get("Version") != "2016-12-05" {
t.Errorf("Version = %q", q.Get("Version"))
}
// Beijing time (UTC+8) formatted with Z suffix, matching the plugin's yE9.
if q.Get("Timestamp") != "2026-08-05T10:30:00Z" {
t.Errorf("Timestamp = %q, want 2026-08-05T10:30:00Z (Beijing time)", q.Get("Timestamp"))
}
if q.Get("Signature") == "" {
t.Error("Signature is empty")
}
if _, err := hex.DecodeString(q.Get("Signature")); err != nil {
t.Errorf("Signature is not hex: %v", err)
}
authz := q.Get("authorization")
if authz == "" {
t.Error("authorization is empty")
}
}
func TestBuildOpURLMissingCreds(t *testing.T) {
s := Signer{}
if _, err := s.BuildOpURL("/x", auth.Credentials{}, "http://x", "POST"); err == nil {
t.Fatal("expected error for missing credentials")
}
}
+226 -28
View File
@@ -4,8 +4,11 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings"
"time" "time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth" "git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
@@ -13,52 +16,247 @@ import (
) )
type Client struct { type Client struct {
BaseURL string LoginBaseURL string
Path string ModelBaseURL string
Creds auth.Credentials ChatPath string
Signer sign.Signer PluginVersion string
HTTPClient *http.Client 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{ return &Client{
BaseURL: baseURL, LoginBaseURL: loginBaseURL,
Path: path, ModelBaseURL: modelBaseURL,
Creds: creds, ChatPath: chatPath,
Signer: signer, PluginVersion: pluginVersion,
HTTPClient: &http.Client{ SM2PrivateKey: sm2PrivateKey,
Timeout: timeout, Signer: signer,
}, HTTPClient: &http.Client{Timeout: timeout, Transport: transport},
} }
} }
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) { // LoginProfile validates AK/SK/token against the Zhanlu gateway
baseURL := c.BaseURL // (POST /api/acepilot/zhanlu/v1/login) and returns the decrypted user profile.
if c.Creds.BaseURL != "" { func (c *Client) LoginProfile(ctx context.Context, creds auth.Credentials) (auth.Profile, error) {
baseURL = c.Creds.BaseURL signedURL, err := c.Signer.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, c.LoginBaseURL, http.MethodPost)
}
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
if err != nil { 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 { if err != nil {
return nil, err return auth.Profile{}, err
} }
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody)) c.setPluginHeaders(req)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody)) 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 { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json") req.Header.Set("Accept", "text/event-stream, application/json")
req.Header.Set("plugin_type", "vscode") req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("service_type", "code")
req.Header.Set("request", randomRequestID())
return c.HTTPClient.Do(req) 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 { func randomRequestID() string {
b := make([]byte, 16) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
+143
View File
@@ -0,0 +1,143 @@
package zhanlu
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
func TestClientFlow(t *testing.T) {
var gotLoginHeaders, gotProvisionHeaders http.Header
var gotChatAuth, gotModelsAuth string
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/v1/login", func(w http.ResponseWriter, r *http.Request) {
gotLoginHeaders = r.Header
// echo a profile whose fields are plaintext (DecryptCredentialOrRaw fallback)
writeJSON(t, w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "cmcc", "team": "ai", "name": "Dev", "telephone": "13800000000",
}})
})
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
gotProvisionHeaders = r.Header
if gotProvisionHeaders.Get("X-Auth-Signature") == "" || gotProvisionHeaders.Get("X-Auth-Timestamp") == "" || gotProvisionHeaders.Get("X-Auth-Nonce") == "" {
t.Errorf("provision request missing X-Auth-* headers: %v", gotProvisionHeaders)
}
if gotProvisionHeaders.Get("X-Auth-Nonce") == "" || len(gotProvisionHeaders.Get("X-Auth-Nonce")) != 32 {
t.Errorf("X-Auth-Nonce should be 32 chars")
}
writeJSON(t, w, map[string]any{"apiKey": "sk-zhanlu-test-123"})
})
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
gotChatAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
})
mux.HandleFunc("/gateway/v1/model/info", func(w http.ResponseWriter, r *http.Request) {
gotModelsAuth = r.Header.Get("Authorization")
writeJSON(t, w, map[string]any{"data": []map[string]any{
{"model_name": "GLM-4.7"},
{"id": "MiniMaxAI/MiniMax-M2.5"},
{"model_info": map[string]any{"id": "qwen-max"}},
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
pub, err := auth.ParsePublicKey(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`)
if err != nil {
t.Fatal(err)
}
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{PublicKey: pub}, 0)
creds := auth.Credentials{AccessKey: "AK", SecretKey: "SK", Token: "TOKEN"}
profile, err := c.LoginProfile(context.Background(), creds)
if err != nil {
t.Fatalf("LoginProfile: %v", err)
}
if profile.Email != "[email protected]" {
t.Fatalf("profile email = %q", profile.Email)
}
if gotLoginHeaders.Get("plugin_type") != "zhanlu_ide" {
t.Errorf("plugin_type header = %q", gotLoginHeaders.Get("plugin_type"))
}
if gotLoginHeaders.Get("plugin_version") != "1.4.2" {
t.Errorf("plugin_version header = %q", gotLoginHeaders.Get("plugin_version"))
}
apiKey, err := c.ProvisionAPIKey(context.Background(), profile.Email, profile.Organization, profile.Team)
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "sk-zhanlu-test-123" {
t.Fatalf("apiKey = %q", apiKey)
}
resp, err := c.ChatCompletions(context.Background(), apiKey, []byte(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}]}`))
if err != nil {
t.Fatalf("ChatCompletions: %v", err)
}
defer resp.Body.Close()
if gotChatAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("chat Authorization = %q", gotChatAuth)
}
models, err := c.Models(context.Background(), apiKey)
if err != nil {
t.Fatalf("Models: %v", err)
}
if len(models) != 3 || models[0] != "GLM-4.7" || models[1] != "MiniMaxAI/MiniMax-M2.5" || models[2] != "qwen-max" {
t.Fatalf("models = %v", models)
}
if gotModelsAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("models Authorization = %q", gotModelsAuth)
}
}
func TestProvisionAPIKeyDefaultsPlaceholders(t *testing.T) {
var body string
mux := http.NewServeMux()
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
body = strings.TrimSpace(string(buf[:n]))
writeJSON(t, w, map[string]any{"data": map[string]any{"key": "k2"}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{}, 0)
apiKey, err := c.ProvisionAPIKey(context.Background(), "[email protected]", "", "")
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "k2" {
t.Fatalf("apiKey = %q", apiKey)
}
var parsed map[string]string
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatal(err)
}
if parsed["organization"] != "未配置" || parsed["team"] != "未配置" {
t.Fatalf("placeholders not applied: %v", parsed)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}