5 Commits
Author SHA1 Message Date
m1saka fa9640d919 Add token usage stats and consolidate credentials in SQLite
build / build (push) Successful in 2m34s
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder.
- Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate.
- Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
2026-08-19 15:52:00 +08:00
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
26 changed files with 2419 additions and 355 deletions
+8 -2
View File
@@ -58,8 +58,13 @@ jobs:
CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-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
ls -lh zhanlu-proxy-linux-*
ls -lh zhanlu-proxy-linux-* zhanlu-proxy-windows-*.exe
- name: Publish release assets
if: startsWith(github.ref, 'refs/tags/v')
@@ -80,10 +85,11 @@ jobs:
echo "release id: ${RELEASE_ID}"
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}" \
-F "attachment=@${f};filename=${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${f}"
done
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -1
View File
@@ -1,4 +1,9 @@
credentials.json
zhanlu.db
zhanlu.db-shm
zhanlu.db-wal
*.db
*.db-shm
*.db-wal
extension/
*.exe
*.log
@@ -7,3 +12,4 @@ extension/
!.env.example
tmp/
temp/
source/
+63 -38
View File
@@ -1,14 +1,16 @@
# Zhanlu Proxy
一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。
一个本地 Go 代理服务,用于读取湛卢v1.4.2插件凭据,按插件认证规则换取模型 API Key,并暴露 OpenAI 兼容接口。
当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。
- OpenAI 兼容接口:`/v1/models``/v1/chat/completions`
- 湛卢签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`
- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions`
- 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
- OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
## 运行
@@ -36,21 +38,19 @@ http://127.0.0.1:8080/
/opt/zhanlu-proxy/zhanlu-proxy
```
凭据文件放在:
数据库放在:
```text
/opt/zhanlu-proxy/credentials.json
/opt/zhanlu-proxy/zhanlu.db
```
创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env`
```env
ZHANLU_LISTEN_ADDR=:8080
ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json
ZHANLU_SERVER_BASE_URL=https://api-wuxi-1.cmecloud.cn:8443
ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat
ZHANLU_MODELS=glm47,minimax-m25
ZHANLU_DEFAULT_MODEL=minimax-m25
ZHANLU_DB_FILE=/opt/zhanlu-proxy/zhanlu.db
ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
ZHANLU_UPSTREAM_TIMEOUT=300s
ZHANLU_LOGIN_PASSWORD=change-this-login-password
OPENAI_COMPAT_API_KEY=change-this-local-secret
@@ -105,27 +105,32 @@ journalctl -u zhanlu-proxy -f
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
- 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`
- 使用本次 `secret` AES 解密响应中的 `ak``sk``license`,得到 `AccessKey``SecretKey``Token`
- 凭据会写入 JSON 文件,后续 OpenAI 兼容接口自动读取
- 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)
- 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`
- 凭据(含 `apiKey``modelBaseUrl`、email 等)会写入本地 SQLite 数据库(`zhanlu.db`),后续 OpenAI 兼容接口自动使用。
默认保存当前执行目录:
默认数据库保存当前执行目录:
```text
credentials.json
zhanlu.db
```
可以通过环境变量覆盖:
```powershell
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json"
$env:ZHANLU_DB_FILE="E:\path\to\zhanlu.db"
```
手机号验证码登录使用 `ZHANLU_SERVER_BASE_URL`,默认公网地址来自插件配置:
凭据仅持久化在数据库中,不再使用 JSON 文件。
手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`):
```powershell
$env:ZHANLU_SERVER_BASE_URL="https://api-wuxi-1.cmecloud.cn:8443"
$env:ZHANLU_MOBILE_LOGIN_BASE_URL="https://ecloud.10086.cn"
$env:ZHANLU_MOBILE_MODEL_BASE_URL="https://ecloud.10086.cn/api/query/aigateway"
```
四共 SSO 的 `/auth/start``/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
灵犀(内网)SSO 的 `/auth/start``/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
## OpenAI 兼容接口
@@ -148,7 +153,7 @@ curl http://127.0.0.1:8080/v1/models
```powershell
curl http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}'
-d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":false}'
```
流式:
@@ -156,7 +161,7 @@ curl http://127.0.0.1:8080/v1/chat/completions `
```powershell
curl -N http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}'
-d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
### 工具调用
@@ -179,19 +184,22 @@ curl http://127.0.0.1:8080/v1/models `
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 |
| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL |
| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 |
| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
| `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL` |
| `ZHANLU_MOBILE_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL |
| `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 |
| `ZHANLU_DB_FILE` | `zhanlu.db` | 本地 SQLite 数据库路径,凭据与 token 统计均存于此,默认当前执行目录 |
| `ZHANLU_STATS_DISABLED` | `false` | 设为 `true` 关闭 token 用量记录(仅停止写入统计,凭据存储不受影响) |
| `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey |
| `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 |
| `ZHANLU_API_KEY` | 空 | 直接从环境变量提供已换取的模型 API Key |
| `ZHANLU_SSO_BASE_URL` | `http://4c.hq.cmcc` | 灵犀内网 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_SSO_EXCHANGE_URL` | `http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken` | 灵犀 SSO 换取用户资料的备用接口 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | `3jw7woww2rvhla6k` | 解密 SSO 返回用户资料字段的 AES key(插件内置默认值) |
| `ZHANLU_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 |
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
| `ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY` | 插件内置 SM2 私钥 | 换取模型 API Key 时 `X-Auth-Signature` 使用的 SM2 私钥,通常不需要设置 |
| `ZHANLU_PLUGIN_VERSION` | `1.4.2` | 请求 `plugin_version` |
| `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 |
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
| `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 |
@@ -202,26 +210,43 @@ curl http://127.0.0.1:8080/v1/models `
服务启动时按以下优先级加载凭据:
1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN`
2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件
1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN``ZHANLU_API_KEY`
2. `ZHANLU_DB_FILE` 数据库中持久化的凭据行
登录页面保存后,运行中的服务会立即使用新凭据。
登录页面保存后,运行中的服务会立即使用新凭据并写入数据库。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写数据库
## Token 消耗统计
代理在 `/v1/chat/completions` 完成后解析上游 `usage`(流式路径在透传 SSE 的同时旁路解析末块 `usage`,非流式路径在聚合时解析),将以下维度写入 `ZHANLU_DB_FILE`
- 时间、模型、流式/非流式
- `prompt_tokens` / `completion_tokens` / `total_tokens` / `reasoning_tokens`(思考模型)
- `cached_tokens`(来自 `prompt_tokens_details.cached_tokens`,提示缓存命中的 token 数)与缓存命中率
- 请求状态(`success` / `upstream_error`)与耗时
缓存命中率为 `cached_tokens / prompt_tokens`,仅在模型/网关支持 prompt 缓存且上游返回 `cached_tokens` 时非零。
管理页(需先登录管理页面)提供:
- `GET /admin/stats`:可视化页面,展示总览、按模型、按日柱状、最近请求明细,带重置按钮。
- `GET /api/stats`JSON 接口,支持 `since`/`until`RFC3339)、`model``limit` 查询参数。
- `POST /api/stats/reset`:清空统计(凭据不受影响)。
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
## 安全说明
- `credentials.json` 包含明文 `AccessKey``SecretKey``Token`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json`
- `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `zhanlu.db`(建议通过 `ZHANLU_DB_FILE` 指向受保护路径)
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature`
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
## 已知限制
- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖
- 灵犀内网 SSO`/auth/start``/auth/callback`)为备用接口,主流程是移动云手机号验证码登录
- 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。
- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。
- UI 模型名 `glm4.7``minimax-m2.5` 会按插件逻辑映射为上游 `glm47``minimax-m25`
- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。
- 模型网关对 OpenAI `stream:false` 请求由代理负责聚合流式响应。
## 验证
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
func main() {
@@ -14,7 +15,20 @@ func main() {
log.Fatal(err)
}
h := server.New(cfg)
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer st.Close()
// Credential precedence at startup: environment variables > db row.
if cfg.Credentials.Validate() != nil && !cfg.Credentials.HasAPIKey() {
if dbCreds, derr := st.LoadCredentials(); derr == nil && (dbCreds.HasAPIKey() || dbCreds.Validate() == nil) {
cfg.Credentials = dbCreds
}
}
h := server.New(cfg, st)
log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr)
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
+17 -1
View File
@@ -1,3 +1,19 @@
module git.misaka.ren/M1saka/zhanlu_proxy
go 1.22
go 1.25.0
require github.com/emmansun/gmsm v0.44.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.56.0 // indirect
)
+24
View File
@@ -0,0 +1,24 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emmansun/gmsm v0.44.1 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4=
github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+8 -19
View File
@@ -33,17 +33,16 @@ func DecryptCredential(ciphertextBase64, key string) (string, error) {
return string(plain), nil
}
func EncryptCredential(plaintext, key string) (string, error) {
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
if err != nil {
return "", err
// DecryptCredentialOrRaw mirrors the plugin's z4A: try AES-ECB decrypt with the
// given key, falling back to the raw value when the field is plaintext.
func DecryptCredentialOrRaw(value, key string) string {
if value == "" {
return ""
}
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
out := make([]byte, len(plain))
for start := 0; start < len(plain); start += aes.BlockSize {
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
if plain, err := DecryptCredential(value, key); err == nil && plain != "" {
return plain
}
return base64.StdEncoding.EncodeToString(out), nil
return value
}
func repeatKey(key string, size int) []byte {
@@ -73,13 +72,3 @@ func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
}
return in[:len(in)-pad], nil
}
func padPKCS7(in []byte, blockSize int) []byte {
pad := blockSize - len(in)%blockSize
out := make([]byte, len(in)+pad)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+18 -28
View File
@@ -1,10 +1,7 @@
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
@@ -13,11 +10,27 @@ type Credentials struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
APIKey string `json:"api_key,omitempty"`
ModelBaseURL string `json:"model_base_url,omitempty"`
Email string `json:"email,omitempty"`
Organization string `json:"organization,omitempty"`
Team string `json:"team,omitempty"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
type Profile struct {
Email string
Organization string
Team string
UserName string
Telephone string
}
func (c Credentials) Validate() error {
if strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" {
return nil
}
if strings.TrimSpace(c.AccessKey) == "" {
return errors.New("access_key is required")
}
@@ -30,29 +43,6 @@ func (c Credentials) Validate() error {
return nil
}
func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path)
if err != nil {
return Credentials{}, err
}
var c Credentials
if err := json.Unmarshal(b, &c); err != nil {
return Credentials{}, err
}
return c, c.Validate()
}
func SaveCredentials(path string, c Credentials) error {
if err := c.Validate(); err != nil {
return err
}
c.SavedAt = time.Now()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, b, 0o600)
func (c Credentials) HasAPIKey() bool {
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
}
+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")
}
}
+52 -36
View File
@@ -10,74 +10,90 @@ import (
"time"
)
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
Body map[string]any `json:"body"`
}
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
// gateway authToken endpoint (POST /api/acepilot/zhanlu/authToken). The
// profile fields may be AES-ECB encrypted with the token decrypt key; each
// field falls back to the raw value when decryption fails.
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Profile, error) {
if strings.TrimSpace(endpoint) == "" {
return Credentials{}, errors.New("exchange endpoint is required")
return Profile{}, errors.New("exchange endpoint is required")
}
if strings.TrimSpace(code) == "" {
return Credentials{}, errors.New("code is required")
}
if strings.TrimSpace(decryptKey) == "" {
return Credentials{}, errors.New("decrypt key is required")
return Profile{}, errors.New("code is required")
}
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
// HTTP/1.1 only: the Zhanlu gateway drops HTTP/2 negotiation.
client = &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ForceAttemptHTTP2: false}}
}
body, _ := json.Marshal(map[string]string{"code": code})
body, _ := json.Marshal(map[string]string{"deputyAccountNumber": code})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Credentials{}, err
return Profile{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return Credentials{}, err
return Profile{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b := make([]byte, 1024)
n, _ := resp.Body.Read(b)
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
}
var exchange ExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
return Credentials{}, err
return Profile{}, err
}
if exchange.ErrorCode != "Success" {
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
if exchange.State != "" && exchange.State != "OK" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
if err != nil {
return Credentials{}, err
profile := Profile{}
for _, m := range []map[string]any{exchange.Body, exchange.Result, exchange.Data} {
if m == nil {
continue
}
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
if err != nil {
return Credentials{}, err
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
}
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
if err != nil {
return Credentials{}, err
}
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
return Profile{}, errors.New("exchange response body missing profile fields")
}
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
v, ok := body[key]
func decryptProfileField(m map[string]any, key, decryptKey string) string {
v, ok := m[key]
if !ok {
return "", fmt.Errorf("response body missing %s", key)
return ""
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("response body %s is not a string", key)
if !ok {
return ""
}
return DecryptCredential(strings.TrimSpace(s), decryptKey)
return DecryptCredentialOrRaw(strings.TrimSpace(s), decryptKey)
}
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
State string `json:"state"`
Body map[string]any `json:"body"`
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
func firstNonEmpty(values ...string) string {
+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)
}
+28 -33
View File
@@ -2,7 +2,6 @@ package config
import (
"os"
"path/filepath"
"strings"
"time"
@@ -11,18 +10,20 @@ import (
type Config struct {
ListenAddr string
ServerBaseURL string
MobileLoginBaseURL string
MobileModelBaseURL string
UpstreamPath string
CredentialsPath string
DBPath string
StatsDisabled bool
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
Models []string
DefaultModel string
SM2PrivateKey string
OpenAIAPIKey string
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
Debug bool
@@ -32,33 +33,32 @@ type Config struct {
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"),
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
StatsDisabled: strings.EqualFold(os.Getenv("ZHANLU_STATS_DISABLED"), "true"),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"),
TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
SM2PrivateKey: getenv("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY", defaultSM2PrivateKey),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "glm47,minimax-m25"))
cfg.Credentials = auth.Credentials{
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
Token: os.Getenv("ZHANLU_TOKEN"),
APIKey: os.Getenv("ZHANLU_API_KEY"),
}
if cfg.Credentials.Validate() == nil {
return cfg, nil
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
cfg.Credentials = creds
}
// Credentials from the environment were incomplete; the store serves the
// persisted row at runtime.
return cfg, nil
}
@@ -69,18 +69,6 @@ func getenv(key, fallback string) string {
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 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -93,8 +81,13 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d
}
func defaultCredentialsPath() string {
return filepath.Join(".", "credentials.json")
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
@@ -104,3 +97,5 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUM
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
-----END PUBLIC KEY-----`
const defaultSM2PrivateKey = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
+1 -11
View File
@@ -28,21 +28,11 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
}
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
model := map[string]string{
"minimax-m2.5": "minimax-m25",
"glm4.7": "glm47",
}[r.Model]
if model == "" {
model = r.Model
}
m := map[string]any{
"model": model,
"model": r.Model,
"messages": r.Messages,
"temperature": 0,
"stream": r.Stream,
"stream_options": map[string]any{"include_usage": true},
"max_tokens": 16000,
"inputs": map[string]any{"aiDevQuestion": ""},
}
for k, v := range r.Extra {
var anyValue any
+676 -117
View File
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
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"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to
// it. The proxy is backed by a temp SQLite store so credentials persist in the
// db during the test instead of a JSON file.
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, *store.Store) {
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\":10,\"completion_tokens\":20,\"total_tokens\":30,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\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)
}
}))
st, err := store.Open(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
cfg := config.Config{
ListenAddr: ":0",
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
TokenDecryptKey: "3jw7woww2rvhla6k",
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
return upstream, proxy, st
}
// 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, st := 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 store should contain the provisioned api key
creds, err := st.LoadCredentials()
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, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// Seed credentials directly with the api key into the store
creds := auth.Credentials{
AccessKey: "AK",
SecretKey: "SK",
Token: "TOKEN",
APIKey: "sk-test-456",
ModelBaseURL: upstream.URL,
Email: "[email protected]",
}
if err := st.SaveCredentials(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)
}
}
// TestStats verifies both streaming and non-streaming chat paths record token
// usage into the store and that GET /api/stats aggregates them correctly.
func TestStats(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
// non-streaming chat
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// streaming chat
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// query stats
resp, err = http.Get(proxy.URL + "/api/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var statsResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&statsResp); err != nil {
t.Fatal(err)
}
if enabled, _ := statsResp["enabled"].(bool); !enabled {
t.Fatalf("stats not enabled: %v", statsResp)
}
s, _ := statsResp["stats"].(map[string]any)
if s == nil {
t.Fatalf("no stats object: %v", statsResp)
}
totals, _ := s["totals"].(map[string]any)
if totals == nil {
t.Fatalf("no totals: %v", s)
}
if got := totNum(totals["requests"]); got != 2 {
t.Fatalf("requests = %v, want 2", totals["requests"])
}
if got := totNum(totals["prompt_tokens"]); got != 20 {
t.Fatalf("prompt_tokens = %v, want 20", totals["prompt_tokens"])
}
if got := totNum(totals["completion_tokens"]); got != 40 {
t.Fatalf("completion_tokens = %v, want 40", totals["completion_tokens"])
}
if got := totNum(totals["total_tokens"]); got != 60 {
t.Fatalf("total_tokens = %v, want 60", totals["total_tokens"])
}
if got := totNum(totals["cached_tokens"]); got != 8 {
t.Fatalf("cached_tokens = %v, want 8", totals["cached_tokens"])
}
if rate, _ := totals["cache_rate"].(float64); rate < 0.39 || rate > 0.41 {
t.Fatalf("cache_rate = %v, want ~0.4", totals["cache_rate"])
}
perModel, _ := s["per_model"].([]any)
if len(perModel) != 1 {
t.Fatalf("per_model = %v, want 1 entry", perModel)
}
}
// totNum extracts an int from a JSON-decoded numeric value (float64).
func totNum(v any) int {
f, _ := v.(float64)
return int(f)
}
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 {
return "", err
}
timestamp := now().Format("2006-01-02T15:04:05Z")
// The plugin formats Beijing time (UTC+8) with a Z suffix via
// new Date(now.getTime()+8*3600*1000) and getUTC* accessors.
timestamp := now().Add(8 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
query := "AccessKey=" + creds.AccessKey +
"&SignatureMethod=HmacSHA1" +
"&SignatureNonce=" + nonce() +
+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")
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package stats defines the types and helpers for OpenAI-compatible token
// usage statistics recorded by the zhanlu proxy. The concrete SQLite-backed
// recorder lives in internal/store; this package is dependency-free so it can
// be referenced by both store and server without import cycles.
package stats
import "time"
// Record is a single chat-completion usage observation.
type Record struct {
Ts time.Time `json:"ts"`
Model string `json:"model"`
Stream bool `json:"stream"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
Status string `json:"status"` // "success" | "upstream_error"
LatencyMs int64 `json:"latency_ms"`
}
// Query filters the recorded stats. Zero-value time fields mean unbounded on
// that end; empty Model means all models. Limit caps the recent-records list
// (0 = default). Stream filters by streaming mode (nil = both).
type Query struct {
Since time.Time
Until time.Time
Model string
Limit int
Stream *bool
}
// Totals aggregates request counts and token sums over a filtered set.
type Totals struct {
Requests int `json:"requests"`
SuccessRequests int `json:"success_requests"`
ErrorRequests int `json:"error_requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
CachedTokens int64 `json:"cached_tokens"`
CacheRate float64 `json:"cache_rate"` // cached_tokens / prompt_tokens, 0..1
}
// ModelStat is a per-model aggregation row.
type ModelStat struct {
Model string `json:"model"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// DayStat is a per-day aggregation row (server-local time, YYYY-MM-DD).
type DayStat struct {
Day string `json:"day"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// Summary is the full result returned by a Recorder's Stats query.
type Summary struct {
Totals Totals `json:"totals"`
PerModel []ModelStat `json:"per_model"`
Daily []DayStat `json:"daily"`
Recent []Record `json:"recent"`
}
// Recorder persists and queries usage statistics. The concrete implementation
// lives in internal/store; the interface is declared here so server code can
// depend on the contract and tests can inject fakes.
type Recorder interface {
Record(r Record) error
Stats(q Query) (*Summary, error)
Reset() error
Close() error
}
+78
View File
@@ -0,0 +1,78 @@
package stats
import (
"encoding/json"
"time"
)
// Usage is the subset of the OpenAI chat-completion usage object the recorder
// persists. Numbers arrive from JSON unmarshal as float64.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
// PromptTokensDetails.CachedTokens is emitted by providers that support
// prompt caching (OpenAI/DeepSeek/Zhipu litellm gateways). Some upstreams
// put cached_tokens at the top level instead.
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
// CompletionTokensDetails.ReasoningTokens is emitted by reasoning models;
// some upstreams put reasoning_tokens at top level instead.
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
}
// ExtractUsage decodes a raw usage value (as produced by encoding/json into an
// any) into token counts. It accepts both full usage maps and raw JSON bytes.
// Missing fields default to 0; a nil v yields zero usage.
func ExtractUsage(v any) Usage {
var u Usage
if v == nil {
return u
}
switch t := v.(type) {
case []byte:
_ = json.Unmarshal(t, &u)
case json.RawMessage:
_ = json.Unmarshal(t, &u)
case map[string]any:
// Re-marshal + unmarshal is the simplest robust path for nested
// *_tokens_details; usage payloads are tiny.
if b, err := json.Marshal(t); err == nil {
_ = json.Unmarshal(b, &u)
}
}
if u.ReasoningTokens == 0 {
u.ReasoningTokens = u.CompletionTokensDetails.ReasoningTokens
}
if u.CachedTokens == 0 {
u.CachedTokens = u.PromptTokensDetails.CachedTokens
}
return u
}
// RecordFromUsage builds a Record from a captured usage value plus context.
func RecordFromUsage(model string, stream bool, usage any, status string, start time.Time) Record {
u := ExtractUsage(usage)
if u.TotalTokens == 0 && (u.PromptTokens != 0 || u.CompletionTokens != 0) {
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return Record{
Ts: time.Now(),
Model: model,
Stream: stream,
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
ReasoningTokens: u.ReasoningTokens,
CachedTokens: u.CachedTokens,
Status: status,
LatencyMs: time.Since(start).Milliseconds(),
}
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
// LoadCredentials reads the persisted credentials. The credentials row always
// exists after Open; an empty row (nothing saved yet) yields a zero-value
// Credentials with a nil error — callers check Validate()/HasAPIKey().
func (s *Store) LoadCredentials() (auth.Credentials, error) {
var c auth.Credentials
var savedAt int64
err := s.db.QueryRow(`SELECT access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at FROM credentials WHERE id = 1`).
Scan(&c.AccessKey, &c.SecretKey, &c.Token, &c.APIKey, &c.ModelBaseURL, &c.Email, &c.Organization, &c.Team, &c.BaseURL, &savedAt)
if err != nil {
return auth.Credentials{}, fmt.Errorf("load credentials: %w", err)
}
c.SavedAt = time.Unix(savedAt, 0).Local()
return c, nil
}
// SaveCredentials upserts the credentials into the single row. It only writes
// when the credentials validate or already carry an API key, so partial /
// env-only creds are not persisted.
func (s *Store) SaveCredentials(c auth.Credentials) error {
if c.Validate() != nil && !c.HasAPIKey() {
return fmt.Errorf("save credentials: %w", c.Validate())
}
c.SavedAt = time.Now()
_, err := s.db.Exec(`INSERT INTO credentials (id, access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
access_key=excluded.access_key, secret_key=excluded.secret_key, token=excluded.token,
api_key=excluded.api_key, model_base_url=excluded.model_base_url, email=excluded.email,
organization=excluded.organization, team=excluded.team, base_url=excluded.base_url,
saved_at=excluded.saved_at`,
c.AccessKey, c.SecretKey, c.Token, c.APIKey, c.ModelBaseURL, c.Email, c.Organization, c.Team, c.BaseURL, c.SavedAt.Unix())
if err != nil {
return fmt.Errorf("save credentials: %w", err)
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
// Compile-time guard: *Store satisfies stats.Recorder.
var _ stats.Recorder = (*Store)(nil)
const defaultRecentLimit = 200
// Record appends a single usage observation.
func (s *Store) Record(rec stats.Record) error {
if rec.Status == "" {
rec.Status = "success"
}
stream := 0
if rec.Stream {
stream = 1
}
_, err := s.db.Exec(`INSERT INTO requests (ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms) VALUES (?,?,?,?,?,?,?,?,?,?)`,
rec.Ts.Unix(), rec.Model, stream, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens, rec.ReasoningTokens, rec.CachedTokens, rec.Status, rec.LatencyMs)
return err
}
// Stats computes the aggregate summary for the given query.
func (s *Store) Stats(q stats.Query) (*stats.Summary, error) {
q = normalizeQuery(q)
where, args := whereClause(q)
sum := &stats.Summary{}
if err := s.scanTotals(sum, where, args); err != nil {
return nil, err
}
if sum.Totals.PromptTokens > 0 {
sum.Totals.CacheRate = float64(sum.Totals.CachedTokens) / float64(sum.Totals.PromptTokens)
}
if err := s.scanPerModel(sum, where, args); err != nil {
return nil, err
}
if err := s.scanDaily(sum, where, args); err != nil {
return nil, err
}
if err := s.scanRecent(sum, q, where, args); err != nil {
return nil, err
}
return sum, nil
}
// Reset deletes all recorded usage statistics (the credentials row is kept).
func (s *Store) Reset() error {
_, err := s.db.Exec(`DELETE FROM requests`)
return err
}
func normalizeQuery(q stats.Query) stats.Query {
if q.Limit <= 0 {
q.Limit = defaultRecentLimit
}
if q.Limit > 5000 {
q.Limit = 5000
}
return q
}
func whereClause(q stats.Query) (string, []any) {
var conds []string
var args []any
if !q.Since.IsZero() {
conds = append(conds, "ts >= ?")
args = append(args, q.Since.Unix())
}
if !q.Until.IsZero() {
conds = append(conds, "ts <= ?")
args = append(args, q.Until.Unix())
}
if q.Model != "" {
conds = append(conds, "model = ?")
args = append(args, q.Model)
}
if q.Stream != nil {
conds = append(conds, "stream = ?")
args = append(args, boolToInt(*q.Stream))
}
if len(conds) == 0 {
return "", args
}
out := ""
for i, p := range conds {
if i > 0 {
out += " AND "
}
out += p
}
return " WHERE " + out, args
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *Store) scanTotals(sum *stats.Summary, where string, args []any) error {
q := `SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status!='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(prompt_tokens),0),
COALESCE(SUM(completion_tokens),0),
COALESCE(SUM(total_tokens),0),
COALESCE(SUM(reasoning_tokens),0),
COALESCE(SUM(cached_tokens),0) FROM requests` + where
row := s.db.QueryRow(q, args...)
var success, failures int64
err := row.Scan(&sum.Totals.Requests, &success, &failures,
&sum.Totals.PromptTokens, &sum.Totals.CompletionTokens,
&sum.Totals.TotalTokens, &sum.Totals.ReasoningTokens, &sum.Totals.CachedTokens)
if err != nil {
return fmt.Errorf("scan totals: %w", err)
}
sum.Totals.SuccessRequests = int(success)
sum.Totals.ErrorRequests = int(failures)
return nil
}
func (s *Store) scanPerModel(sum *stats.Summary, where string, args []any) error {
q := `SELECT model, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY model ORDER BY SUM(total_tokens) DESC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan per-model: %w", err)
}
defer rows.Close()
for rows.Next() {
var m stats.ModelStat
if err := rows.Scan(&m.Model, &m.Requests, &m.PromptTokens, &m.CompletionTokens, &m.TotalTokens, &m.CachedTokens); err != nil {
return err
}
sum.PerModel = append(sum.PerModel, m)
}
return rows.Err()
}
func (s *Store) scanDaily(sum *stats.Summary, where string, args []any) error {
q := `SELECT date(ts,'unixepoch','localtime') AS day, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY day ORDER BY day ASC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan daily: %w", err)
}
defer rows.Close()
for rows.Next() {
var d stats.DayStat
if err := rows.Scan(&d.Day, &d.Requests, &d.PromptTokens, &d.CompletionTokens, &d.TotalTokens, &d.CachedTokens); err != nil {
return err
}
sum.Daily = append(sum.Daily, d)
}
return rows.Err()
}
func (s *Store) scanRecent(sum *stats.Summary, q stats.Query, where string, args []any) error {
limit := q.Limit
if limit <= 0 {
limit = defaultRecentLimit
}
query := `SELECT ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms FROM requests` + where + " ORDER BY id DESC LIMIT ?"
rows, err := s.db.Query(query, append(args, limit)...)
if err != nil {
return fmt.Errorf("scan recent: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec stats.Record
var ts int64
var stream int
if err := rows.Scan(&ts, &rec.Model, &stream, &rec.PromptTokens, &rec.CompletionTokens, &rec.TotalTokens, &rec.ReasoningTokens, &rec.CachedTokens, &rec.Status, &rec.LatencyMs); err != nil {
return err
}
rec.Ts = time.Unix(ts, 0).Local()
rec.Stream = stream == 1
sum.Recent = append(sum.Recent, rec)
}
return rows.Err()
}
+89
View File
@@ -0,0 +1,89 @@
// Package store owns the single embedded SQLite database backing the zhanlu
// proxy: token-usage records (the requests table) and the persisted login
// credentials (the credentials table, single-tenant single row). It implements
// stats.Recorder for usage tracking and exposes Load/Save for credentials.
package store
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// Store is the single owner of the proxy's SQLite database handle.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the database at path and ensures both tables exist.
// SQLite is opened with WAL journaling and a busy timeout so concurrent reads
// (stats queries) and writes (request records, credential saves) do not
// collide.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("open db %q: %w", path, err)
}
if err := ensureSchema(db); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close releases the database handle.
func (s *Store) Close() error {
return s.db.Close()
}
func ensureSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
model TEXT NOT NULL,
stream INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success',
latency_ms INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
model_base_url TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
organization TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '',
base_url TEXT NOT NULL DEFAULT '',
saved_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts)`,
`CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model)`,
// Ensure the single credentials row exists so UPSERTs and SELECTs always
// have a target.
`INSERT INTO credentials (id) VALUES (1) ON CONFLICT(id) DO NOTHING`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
return fmt.Errorf("schema: %w", err)
}
}
// Add cached_tokens to databases created before this column existed. SQLite
// returns "duplicate column name" when it already exists; that is expected
// and ignored.
if _, err := db.Exec(`ALTER TABLE requests ADD COLUMN cached_tokens INTEGER NOT NULL DEFAULT 0`); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migrate cached_tokens: %w", err)
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"path/filepath"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestCredentialsRoundTrip(t *testing.T) {
st := newTestStore(t)
in := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOK",
APIKey: "sk-1", ModelBaseURL: "https://up.example", Email: "[email protected]",
Organization: "org", Team: "team",
}
if err := st.SaveCredentials(in); err != nil {
t.Fatalf("save: %v", err)
}
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load: %v", err)
}
if got.APIKey != "sk-1" || got.Email != "[email protected]" || got.Organization != "org" {
t.Fatalf("round-trip mismatch: %+v", got)
}
if got.SavedAt.IsZero() {
t.Fatalf("saved_at not set")
}
}
func TestEmptyLoadReturnsZero(t *testing.T) {
st := newTestStore(t)
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load on empty store: %v", err)
}
if got.HasAPIKey() {
t.Fatalf("expected no api key on empty store, got %+v", got)
}
}
func TestRecordStatsReset(t *testing.T) {
st := newTestStore(t)
now := time.Now()
recs := []stats.Record{
{Ts: now, Model: "glm-4.7", Stream: false, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4, Status: "success", LatencyMs: 5},
{Ts: now, Model: "glm-4.7", Stream: true, PromptTokens: 5, CompletionTokens: 5, TotalTokens: 10, Status: "success", LatencyMs: 8},
{Ts: now, Model: "minimax", Stream: false, PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, Status: "upstream_error", LatencyMs: 3},
}
for _, r := range recs {
if err := st.Record(r); err != nil {
t.Fatalf("record: %v", err)
}
}
sum, err := st.Stats(stats.Query{})
if err != nil {
t.Fatalf("stats: %v", err)
}
if sum.Totals.Requests != 3 {
t.Fatalf("requests = %d, want 3", sum.Totals.Requests)
}
if sum.Totals.SuccessRequests != 2 || sum.Totals.ErrorRequests != 1 {
t.Fatalf("success/error = %d/%d, want 2/1", sum.Totals.SuccessRequests, sum.Totals.ErrorRequests)
}
if sum.Totals.TotalTokens != 42 {
t.Fatalf("total tokens = %d, want 42", sum.Totals.TotalTokens)
}
if sum.Totals.CachedTokens != 4 {
t.Fatalf("cached tokens = %d, want 4", sum.Totals.CachedTokens)
}
// prompt total = 10+5+1 = 16, cached = 4 => 0.25
if sum.Totals.CacheRate < 0.24 || sum.Totals.CacheRate > 0.26 {
t.Fatalf("cache rate = %v, want ~0.25", sum.Totals.CacheRate)
}
if len(sum.PerModel) != 2 {
t.Fatalf("per-model entries = %d, want 2", len(sum.PerModel))
}
// glm-4.7 should lead on total tokens (40 vs 2)
if sum.PerModel[0].Model != "glm-4.7" || sum.PerModel[0].TotalTokens != 40 || sum.PerModel[0].CachedTokens != 4 {
t.Fatalf("top model = %+v, want glm-4.7/40/4 cached", sum.PerModel[0])
}
if len(sum.Recent) != 3 {
t.Fatalf("recent entries = %d, want 3", len(sum.Recent))
}
// most recent first (id desc) => minimax record
if sum.Recent[0].Model != "minimax" {
t.Fatalf("most recent = %+v, want minimax", sum.Recent[0])
}
// model filter
sumF, _ := st.Stats(stats.Query{Model: "minimax"})
if sumF.Totals.Requests != 1 || sumF.Totals.TotalTokens != 2 {
t.Fatalf("filtered stats = %+v, want 1/2", sumF.Totals)
}
if err := st.Reset(); err != nil {
t.Fatalf("reset: %v", err)
}
sum2, _ := st.Stats(stats.Query{})
if sum2.Totals.Requests != 0 {
t.Fatalf("after reset requests = %d, want 0", sum2.Totals.Requests)
}
// credentials must survive a stats reset
creds, _ := st.LoadCredentials()
_ = creds
}
+223 -25
View File
@@ -4,8 +4,11 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
@@ -13,52 +16,247 @@ import (
)
type Client struct {
BaseURL string
Path string
Creds auth.Credentials
LoginBaseURL string
ModelBaseURL string
ChatPath string
PluginVersion string
SM2PrivateKey string
Signer sign.Signer
HTTPClient *http.Client
}
func NewClient(baseURL, path string, creds auth.Credentials, signer sign.Signer, timeout time.Duration) *Client {
func NewClient(loginBaseURL, modelBaseURL, chatPath, pluginVersion, sm2PrivateKey string, signer sign.Signer, timeout time.Duration) *Client {
// The Zhanlu gateway rejects HTTP/2 ALPN negotiation (connection EOF on
// handshake), so force HTTP/1.1 for all upstream requests.
transport := &http.Transport{ForceAttemptHTTP2: false}
return &Client{
BaseURL: baseURL,
Path: path,
Creds: creds,
LoginBaseURL: loginBaseURL,
ModelBaseURL: modelBaseURL,
ChatPath: chatPath,
PluginVersion: pluginVersion,
SM2PrivateKey: sm2PrivateKey,
Signer: signer,
HTTPClient: &http.Client{
Timeout: timeout,
},
HTTPClient: &http.Client{Timeout: timeout, Transport: transport},
}
}
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) {
baseURL := c.BaseURL
if c.Creds.BaseURL != "" {
baseURL = c.Creds.BaseURL
}
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
// LoginProfile validates AK/SK/token against the Zhanlu gateway
// (POST /api/acepilot/zhanlu/v1/login) and returns the decrypted user profile.
func (c *Client) LoginProfile(ctx context.Context, creds auth.Credentials) (auth.Profile, error) {
signedURL, err := c.Signer.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, c.LoginBaseURL, http.MethodPost)
if err != nil {
return nil, err
return auth.Profile{}, err
}
encryptedBody, err := auth.EncryptCredential(string(body), c.Creds.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, strings.NewReader(`{}`))
if err != nil {
return nil, err
return auth.Profile{}, err
}
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody))
c.setPluginHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return auth.Profile{}, err
}
defer resp.Body.Close()
var out struct {
State string `json:"state"`
Body struct {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
Name string `json:"name"`
Telephone string `json:"telephone"`
} `json:"body"`
}
if err := decodeJSON(resp, &out); err != nil {
return auth.Profile{}, err
}
if out.State != "OK" {
return auth.Profile{}, fmt.Errorf("zhanlu login failed: state=%s", out.State)
}
return auth.Profile{
Email: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Email), creds.Token),
Organization: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Organization), creds.Token),
Team: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Team), creds.Token),
UserName: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Name), creds.Token),
Telephone: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Telephone), creds.Token),
}, nil
}
// ProvisionAPIKey requests a Zhanlu gateway API key
// (POST {modelBaseUrl}/user/api/v2/external/key/get-or-create) using the
// SM2-signed X-Auth-* headers, then returns the apiKey. Empty organization and
// team default to the plugin's "未配置" placeholder.
func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team string) (string, error) {
if strings.TrimSpace(c.SM2PrivateKey) == "" {
return "", fmt.Errorf("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY is required to provision an API key")
}
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := firstNonEmpty(organization, "未配置")
tm := firstNonEmpty(team, "未配置")
// Field order matters: the SM2 signature covers the exact JSON body bytes,
// matching the plugin's JSON.stringify({email, organization, team}).
body, err := json.Marshal(struct {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
}{email, org, tm})
if err != nil {
return "", err
}
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/user/api/v2/external/key/get-or-create"
timestamp := fmt.Sprintf("%d", time.Now().Unix())
nonce := randomAlnum(32)
signature, err := auth.SignSM2Authorization(c.SM2PrivateKey, timestamp+":"+nonce+":"+string(body))
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Signature", signature)
req.Header.Set("X-Auth-Timestamp", timestamp)
req.Header.Set("X-Auth-Nonce", nonce)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("zhanlu api key provisioning returned %d: %s", resp.StatusCode, string(b))
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
apiKey := findString(out, "apiKey", "key", "api_key")
if apiKey == "" {
return "", fmt.Errorf("zhanlu api key provisioning response missing apiKey")
}
return apiKey, nil
}
// ChatCompletions posts an OpenAI-compatible body to the Zhanlu gateway chat
// endpoint authenticated with the provisioned API key.
func (c *Client) ChatCompletions(ctx context.Context, apiKey string, body []byte) (*http.Response, error) {
baseURL := c.ModelBaseURL
path := c.ChatPath
if path == "" {
path = "/chat/completions"
}
endpoint := strings.TrimRight(baseURL, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("service_type", "code")
req.Header.Set("request", randomRequestID())
req.Header.Set("Authorization", "Bearer "+apiKey)
return c.HTTPClient.Do(req)
}
// Models lists model ids exposed by the gateway model-info endpoint
// (GET {modelBaseUrl}/gateway/v1/model/info) with the provisioned API key.
func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/gateway/v1/model/info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("zhanlu model info returned %d: %s", resp.StatusCode, string(b))
}
var out struct {
Data []struct {
ModelName string `json:"model_name"`
ID string `json:"id"`
ModelInfo struct {
ID string `json:"id"`
} `json:"model_info"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
seen := map[string]bool{}
models := make([]string, 0, len(out.Data))
for _, m := range out.Data {
id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
models = append(models, id)
}
return models, nil
}
func (c *Client) setPluginHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", c.PluginVersion)
req.Header.Set("request", randomRequestID())
}
func decodeJSON(resp *http.Response, out any) error {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("zhanlu upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func findString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
// The plugin searches the root and the data/body/result/payload containers.
for _, container := range []string{"data", "body", "result", "payload"} {
if v, ok := m[container].(map[string]any); ok {
if s := findString(v, keys...); s != "" {
return s
}
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
func randomAlnum(n int) string {
b := make([]byte, n)
rand.Read(b)
for i := range b {
b[i] = alnum[int(b[i])%len(alnum)]
}
return string(b)
}
func randomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
+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)
}