6 Commits
Author SHA1 Message Date
root 8a1cf4d61f Redesign admin UI: unified tabbed console, humanized and paginated stats
build / build (push) Successful in 2m32s
- Unify /admin/login and /admin/stats into a single /admin page with tabs
  (Token stats default, credential login); old paths redirect to /admin.
- Restyle from dark glass-morphism to a clean light theme (single accent,
  system font stack, 1px-bordered cards, generous whitespace).
- Humanize token counts with K/M/B suffixes via a humanNum template func.
- Paginate recent requests client-side (10 per page) with a compact pager.
- Drop the database path from the UI.
- Fix mobile horizontal overflow caused by grid min-width:auto.
2026-08-19 17:12:30 +08:00
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 2527 additions and 474 deletions
+8 -2
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 }}
+8 -1
View File
@@ -1,4 +1,9 @@
credentials.json zhanlu.db
zhanlu.db-shm
zhanlu.db-wal
*.db
*.db-shm
*.db-wal
extension/ extension/
*.exe *.exe
*.log *.log
@@ -7,3 +12,5 @@ extension/
!.env.example !.env.example
tmp/ tmp/
temp/ temp/
source/
output/
+63 -38
View File
@@ -1,14 +1,16 @@
# 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` 结果续传。
- 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 /opt/zhanlu-proxy/zhanlu-proxy
``` ```
凭据文件放在: 数据库放在:
```text ```text
/opt/zhanlu-proxy/credentials.json /opt/zhanlu-proxy/zhanlu.db
``` ```
创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env` 创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env`
```env ```env
ZHANLU_LISTEN_ADDR=:8080 ZHANLU_LISTEN_ADDR=:8080
ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json ZHANLU_DB_FILE=/opt/zhanlu-proxy/zhanlu.db
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 ZHANLU_LOGIN_PASSWORD=change-this-login-password
OPENAI_COMPAT_API_KEY=change-this-local-secret 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/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 等)会写入本地 SQLite 数据库(`zhanlu.db`),后续 OpenAI 兼容接口自动使用。
默认保存当前执行目录: 默认数据库保存当前执行目录:
```text ```text
credentials.json zhanlu.db
``` ```
可以通过环境变量覆盖: 可以通过环境变量覆盖:
```powershell ```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 ```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 兼容接口
@@ -148,7 +153,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}'
``` ```
流式: 流式:
@@ -156,7 +161,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}'
``` ```
### 工具调用 ### 工具调用
@@ -179,19 +184,22 @@ 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_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 | | `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 |
| `ZHANLU_DB_FILE` | `zhanlu.db` | 本地 SQLite 数据库路径,凭据与 token 统计均存于此,默认当前执行目录 |
| `ZHANLU_STATS_DISABLED` | `false` | 设为 `true` 关闭 token 用量记录(仅停止写入统计,凭据存储不受影响) |
| `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_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` 1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN``ZHANLU_API_KEY`
2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件 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`,请不要提交到仓库。 - `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json` - 默认保存在当前执行目录的 `zhanlu.db`(建议通过 `ZHANLU_DB_FILE` 指向受保护路径)
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。 - 建议设置 `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`,当前代理未接入该特殊分支。
## 验证 ## 验证
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config" "git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server" "git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
) )
func main() { func main() {
@@ -14,7 +15,20 @@ func main() {
log.Fatal(err) 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("zhanlu proxy listening on %s", cfg.ListenAddr)
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr) log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
+17 -1
View File
@@ -1,3 +1,19 @@
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 (
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 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
}
+18 -28
View File
@@ -1,10 +1,7 @@
package auth package auth
import ( import (
"encoding/json"
"errors" "errors"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
) )
@@ -13,11 +10,27 @@ 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"`
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"` BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"` 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,29 +43,6 @@ func (c Credentials) Validate() error {
return nil return nil
} }
func LoadCredentials(path string) (Credentials, error) { func (c Credentials) HasAPIKey() bool {
b, err := os.ReadFile(path) return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
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)
} }
+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" "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
} }
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey) profile.Email = decryptProfileField(m, "email", decryptKey)
if err != nil { profile.Organization = decryptProfileField(m, "organization", decryptKey)
return Credentials{}, err 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) { 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)
}
+28 -33
View File
@@ -2,7 +2,6 @@ package config
import ( import (
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -11,18 +10,20 @@ import (
type Config struct { type Config struct {
ListenAddr string ListenAddr string
ServerBaseURL string MobileLoginBaseURL string
MobileModelBaseURL string
UpstreamPath string UpstreamPath string
CredentialsPath string DBPath string
StatsDisabled bool
SSOExchangeURL string SSOExchangeURL string
SSOBaseURL string SSOBaseURL string
TokenDecryptKey string TokenDecryptKey string
PublicKeyPEM string PublicKeyPEM string
PhonePublicKeyPEM string PhonePublicKeyPEM string
Models []string SM2PrivateKey string
DefaultModel string
OpenAIAPIKey string OpenAIAPIKey string
LoginPassword string LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration UpstreamTimeout time.Duration
StreamIdleTimout time.Duration StreamIdleTimout time.Duration
Debug bool Debug bool
@@ -32,33 +33,32 @@ type Config struct {
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"), DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"), StatsDisabled: strings.EqualFold(os.Getenv("ZHANLU_STATS_DISABLED"), "true"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"), 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), PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM), 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"), OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"), LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second), UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second), StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"), 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 { // Credentials from the environment were incomplete; the store serves the
return cfg, nil // persisted row at runtime.
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
cfg.Credentials = creds
}
return cfg, nil return cfg, nil
} }
@@ -69,18 +69,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 == "" {
@@ -93,8 +81,13 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d return d
} }
func defaultCredentialsPath() string { func firstNonEmpty(values ...string) string {
return filepath.Join(".", "credentials.json") for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
} }
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY----- const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
@@ -104,3 +97,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
+756 -209
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 { 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")
}
}
+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" "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
PluginVersion string
SM2PrivateKey string
Signer sign.Signer Signer sign.Signer
HTTPClient *http.Client 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,
PluginVersion: pluginVersion,
SM2PrivateKey: sm2PrivateKey,
Signer: signer, Signer: signer,
HTTPClient: &http.Client{ HTTPClient: &http.Client{Timeout: timeout, Transport: transport},
Timeout: timeout,
},
} }
} }
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)
}