Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1cf4d61f | ||
|
|
fa9640d919 | ||
|
|
dd5c560b64 | ||
|
|
2e402934ea |
@@ -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 }}
|
||||||
|
|||||||
+7
-1
@@ -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
|
||||||
@@ -8,3 +13,4 @@ extension/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
source/
|
source/
|
||||||
|
output/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
|
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
|
||||||
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
- 上游 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 接口。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -37,17 +38,17 @@ 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_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
|
ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
|
||||||
ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
|
ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
|
||||||
ZHANLU_UPSTREAM_TIMEOUT=300s
|
ZHANLU_UPSTREAM_TIMEOUT=300s
|
||||||
@@ -106,20 +107,22 @@ journalctl -u zhanlu-proxy -f
|
|||||||
- 使用本次 `secret` AES 解密响应中的 `ak`、`sk`、`license`,得到 `AccessKey`、`SecretKey`、`Token`。
|
- 使用本次 `secret` AES 解密响应中的 `ak`、`sk`、`license`,得到 `AccessKey`、`SecretKey`、`Token`。
|
||||||
- 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`(RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)。
|
- 按插件 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`。
|
- 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`。
|
||||||
- 凭据(含 `apiKey`、`modelBaseUrl`、email 等)会写入 JSON 文件,后续 OpenAI 兼容接口自动使用。
|
- 凭据(含 `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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
凭据仅持久化在数据库中,不再使用 JSON 文件。
|
||||||
|
|
||||||
手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`):
|
手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -184,7 +187,8 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
| `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL`) |
|
| `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_MOBILE_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL |
|
||||||
| `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 |
|
| `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 |
|
||||||
| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
|
| `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 |
|
||||||
@@ -207,14 +211,33 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
服务启动时按以下优先级加载凭据:
|
服务启动时按以下优先级加载凭据:
|
||||||
|
|
||||||
1. 环境变量 `ZHANLU_ACCESS_KEY`、`ZHANLU_SECRET_KEY`、`ZHANLU_TOKEN` 或 `ZHANLU_API_KEY`。
|
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 并回写凭据文件。
|
登录页面保存后,运行中的服务会立即使用新凭据并写入数据库。若环境中只有 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` 和 `apiKey`,请不要提交到仓库。
|
- `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 参数脱敏。
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,4 +4,16 @@ go 1.25.0
|
|||||||
|
|
||||||
require github.com/emmansun/gmsm v0.44.1
|
require github.com/emmansun/gmsm v0.44.1
|
||||||
|
|
||||||
require golang.org/x/crypto v0.54.0 // indirect
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +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 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4=
|
||||||
github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM=
|
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 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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=
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -49,30 +46,3 @@ func (c Credentials) Validate() error {
|
|||||||
func (c Credentials) HasAPIKey() bool {
|
func (c Credentials) HasAPIKey() bool {
|
||||||
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
|
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,7 +13,8 @@ type Config struct {
|
|||||||
MobileLoginBaseURL string
|
MobileLoginBaseURL string
|
||||||
MobileModelBaseURL 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
|
||||||
@@ -36,7 +36,8 @@ func Load() (Config, error) {
|
|||||||
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
|
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"),
|
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
|
||||||
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
|
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
|
||||||
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
|
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"),
|
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"),
|
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"),
|
TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
|
||||||
@@ -56,12 +57,8 @@ func Load() (Config, error) {
|
|||||||
Token: os.Getenv("ZHANLU_TOKEN"),
|
Token: os.Getenv("ZHANLU_TOKEN"),
|
||||||
APIKey: os.Getenv("ZHANLU_API_KEY"),
|
APIKey: os.Getenv("ZHANLU_API_KEY"),
|
||||||
}
|
}
|
||||||
if cfg.Credentials.Validate() == nil || cfg.Credentials.HasAPIKey() {
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +90,6 @@ func firstNonEmpty(values ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultCredentialsPath() string {
|
|
||||||
return filepath.Join(".", "credentials.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
||||||
-----END PUBLIC KEY-----`
|
-----END PUBLIC KEY-----`
|
||||||
|
|||||||
+608
-156
@@ -21,6 +21,8 @@ import (
|
|||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,10 +30,12 @@ type Server struct {
|
|||||||
cfg config.Config
|
cfg config.Config
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
loginSession string
|
loginSession string
|
||||||
|
st *store.Store
|
||||||
|
statsEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) http.Handler {
|
func New(cfg config.Config, st *store.Store) http.Handler {
|
||||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
|
||||||
if cfg.LoginPassword != "" {
|
if cfg.LoginPassword != "" {
|
||||||
s.loginSession = randomSessionToken()
|
s.loginSession = randomSessionToken()
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,9 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /", s.index)
|
s.mux.HandleFunc("GET /", s.index)
|
||||||
s.mux.HandleFunc("GET /healthz", s.healthz)
|
s.mux.HandleFunc("GET /healthz", s.healthz)
|
||||||
s.mux.HandleFunc("GET /login", s.loginPage)
|
s.mux.HandleFunc("GET /login", s.loginPage)
|
||||||
s.mux.HandleFunc("GET /admin/login", s.adminLoginPage)
|
s.mux.HandleFunc("GET /admin", s.adminPage)
|
||||||
|
s.mux.HandleFunc("GET /admin/login", s.redirectAdmin)
|
||||||
|
s.mux.HandleFunc("GET /admin/stats", s.redirectAdmin)
|
||||||
s.mux.HandleFunc("POST /api/login", s.passwordLogin)
|
s.mux.HandleFunc("POST /api/login", s.passwordLogin)
|
||||||
s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
|
s.mux.HandleFunc("POST /api/logout", s.passwordLogout)
|
||||||
s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
|
s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO))
|
||||||
@@ -53,6 +59,8 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
|
s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials))
|
||||||
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
|
s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials))
|
||||||
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||||
|
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
||||||
|
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
||||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
||||||
}
|
}
|
||||||
@@ -84,24 +92,46 @@ func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
|
|
||||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.hasLoginSession(r) {
|
if s.hasLoginSession(r) {
|
||||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.LoginPassword == "" {
|
if s.cfg.LoginPassword == "" {
|
||||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false})
|
_ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "PasswordEnabled": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminPage renders the unified management console: a single page with tabs for
|
||||||
|
// token statistics (default) and credential (phone code) login. It requires an
|
||||||
|
// authenticated management session; without one it bounces to /login.
|
||||||
|
func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.hasLoginSession(r) {
|
if !s.hasLoginSession(r) {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var summary *stats.Summary
|
||||||
|
enabled := s.statsEnabled
|
||||||
|
if s.st != nil {
|
||||||
|
if sm, err := s.st.Stats(stats.Query{}); err == nil {
|
||||||
|
summary = sm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary == nil {
|
||||||
|
summary = &stats.Summary{}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true})
|
_ = adminTemplate.Execute(w, map[string]any{
|
||||||
|
"Enabled": enabled,
|
||||||
|
"Stats": summary,
|
||||||
|
"DBPath": s.cfg.DBPath,
|
||||||
|
"PasswordEnabled": s.cfg.LoginPassword != "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -159,7 +189,7 @@ func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderLoginResult(w, false, err.Error())
|
s.renderLoginResult(w, false, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
if err := s.st.SaveCredentials(creds); err != nil {
|
||||||
s.renderLoginResult(w, false, err.Error())
|
s.renderLoginResult(w, false, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,12 +334,12 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
if err := s.st.SaveCredentials(creds); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.cfg.Credentials = creds
|
s.cfg.Credentials = creds
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath, "access_key": mask(creds.AccessKey)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// provisionCredentials logs the AK/SK/token into the Zhanlu gateway to obtain
|
// provisionCredentials logs the AK/SK/token into the Zhanlu gateway to obtain
|
||||||
@@ -451,14 +481,14 @@ func randomRequestID() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
c, err := s.st.LoadCredentials()
|
||||||
if err != nil {
|
if err != nil || (c.Validate() != nil && !c.HasAPIKey()) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.CredentialsPath})
|
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.DBPath})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"path": s.cfg.CredentialsPath,
|
"path": s.cfg.DBPath,
|
||||||
"access_key": mask(c.AccessKey),
|
"access_key": mask(c.AccessKey),
|
||||||
"has_api_key": c.APIKey != "",
|
"has_api_key": c.APIKey != "",
|
||||||
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
|
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
|
||||||
@@ -482,12 +512,12 @@ func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
c = provisioned
|
c = provisioned
|
||||||
}
|
}
|
||||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
|
if err := s.st.SaveCredentials(c); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.cfg.Credentials = c
|
s.cfg.Credentials = c
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -516,12 +546,65 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.BaseURL != "" {
|
if in.BaseURL != "" {
|
||||||
creds.ModelBaseURL = in.BaseURL
|
creds.ModelBaseURL = in.BaseURL
|
||||||
}
|
}
|
||||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
if err := s.st.SaveCredentials(creds); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.cfg.Credentials = creds
|
s.cfg.Credentials = creds
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.DBPath})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.st == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := stats.Query{
|
||||||
|
Model: r.URL.Query().Get("model"),
|
||||||
|
Limit: parseLimit(r.URL.Query().Get("limit")),
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("since"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
q.Since = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("until"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
q.Until = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary, err := s.st.Stats(q)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"enabled": s.statsEnabled, "stats": summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.st == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.Reset(); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimit(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
if n > 5000 {
|
||||||
|
return 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -554,6 +637,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Model == "" {
|
if req.Model == "" {
|
||||||
req.Model = "zhanlu/auto"
|
req.Model = "zhanlu/auto"
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
clientWantsStream := req.Stream
|
clientWantsStream := req.Stream
|
||||||
req.Stream = true
|
req.Stream = true
|
||||||
body, err := req.MarshalForUpstream()
|
body, err := req.MarshalForUpstream()
|
||||||
@@ -569,7 +653,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.cfg.Credentials = creds
|
s.cfg.Credentials = creds
|
||||||
_ = auth.SaveCredentials(s.cfg.CredentialsPath, creds)
|
_ = s.st.SaveCredentials(creds)
|
||||||
}
|
}
|
||||||
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
client, err := s.zhanluClientWithBase(modelBaseURL)
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
@@ -584,6 +668,7 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg = redactSensitive(err.Error())
|
msg = redactSensitive(err.Error())
|
||||||
}
|
}
|
||||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
|
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
|
||||||
|
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -594,33 +679,93 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg += ": " + string(b)
|
msg += ": " + string(b)
|
||||||
}
|
}
|
||||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
|
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
|
||||||
|
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if clientWantsStream {
|
if clientWantsStream {
|
||||||
s.proxyStream(w, resp)
|
usage, status := s.proxyStream(w, resp)
|
||||||
|
s.record(req.Model, true, usage, status, start)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.aggregateStream(w, resp, req.Model)
|
usage, status := s.aggregateStream(w, resp, req.Model)
|
||||||
|
s.record(req.Model, false, usage, status, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) {
|
// record appends a usage observation to the stats store when collection is
|
||||||
|
// enabled. It never affects the response path; recording errors are ignored.
|
||||||
|
func (s *Server) record(model string, stream bool, usage any, status string, start time.Time) {
|
||||||
|
if !s.statsEnabled || s.st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.st.Record(stats.RecordFromUsage(model, stream, usage, status, start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) proxyStream(w http.ResponseWriter, resp *http.Response) (any, string) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
_, err := io.Copy(w, resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
var usage any
|
||||||
|
status := "success"
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if line != "" {
|
||||||
|
if _, werr := io.WriteString(w, line); werr != nil {
|
||||||
|
// client disconnected mid-stream; stop forwarding
|
||||||
|
status = "upstream_error"
|
||||||
|
break
|
||||||
|
}
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
if payload := sseDataPayload(line); payload != "" && payload != "[DONE]" {
|
||||||
|
var evt struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
Usage any `json:"usage"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(payload), &evt) == nil {
|
||||||
|
if evt.State == "ERROR" {
|
||||||
|
status = "upstream_error"
|
||||||
|
}
|
||||||
|
if evt.Usage != nil {
|
||||||
|
usage = evt.Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// upstream read error: surface an error event to the client, mirroring
|
||||||
|
// the previous io.Copy behavior, then mark the request as failed.
|
||||||
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
|
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
|
||||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
status = "upstream_error"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage, status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) {
|
// sseDataPayload returns the payload following a "data:" SSE line, or "" if the
|
||||||
|
// line is not a data line. Mirrors the parsing in forEachSSEChunk.
|
||||||
|
func sseDataPayload(line string) string {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(trimmed, "data:") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (any, string) {
|
||||||
var content, reasoning, id string
|
var content, reasoning, id string
|
||||||
var usage any
|
var usage any
|
||||||
finishReason := "stop"
|
finishReason := "stop"
|
||||||
@@ -690,7 +835,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
|
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
|
||||||
return
|
return nil, "upstream_error"
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
id = "chatcmpl-" + randomRequestID()
|
id = "chatcmpl-" + randomRequestID()
|
||||||
@@ -719,6 +864,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
|||||||
result["usage"] = usage
|
result["usage"] = usage
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
return usage, "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
// forEachSSEChunk feeds each non-empty data: payload to fn, skipping keep-alive
|
// forEachSSEChunk feeds each non-empty data: payload to fn, skipping keep-alive
|
||||||
@@ -753,7 +899,7 @@ func (s *Server) currentCredentials() (auth.Credentials, error) {
|
|||||||
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
|
if s.cfg.Credentials.Validate() == nil || s.cfg.Credentials.HasAPIKey() {
|
||||||
return s.cfg.Credentials, nil
|
return s.cfg.Credentials, nil
|
||||||
}
|
}
|
||||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
c, err := s.st.LoadCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth.Credentials{}, err
|
return auth.Credentials{}, err
|
||||||
}
|
}
|
||||||
@@ -821,6 +967,35 @@ func firstNonEmpty(a, b string) string {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// humanNum renders an integer-like value with K/M/B suffixes for compact,
|
||||||
|
// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 ->
|
||||||
|
// "1.5M"). Values below 1000 are shown as plain integers. It accepts int,
|
||||||
|
// int64 and float64 so the same template func works for both Record (int)
|
||||||
|
// and Totals/ModelStat/DayStat (int64) fields.
|
||||||
|
func humanNum(v any) string {
|
||||||
|
var f float64
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
f = float64(n)
|
||||||
|
case int64:
|
||||||
|
f = float64(n)
|
||||||
|
case float64:
|
||||||
|
f = n
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case f < 1000:
|
||||||
|
return fmt.Sprintf("%d", int64(f))
|
||||||
|
case f < 1e6:
|
||||||
|
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e3), ".0") + "K"
|
||||||
|
case f < 1e9:
|
||||||
|
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e6), ".0") + "M"
|
||||||
|
default:
|
||||||
|
return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e9), ".0") + "B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func redactSensitive(s string) string {
|
func redactSensitive(s string) string {
|
||||||
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
|
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
|
||||||
s = redactQueryValue(s, key)
|
s = redactQueryValue(s, key)
|
||||||
@@ -851,81 +1026,415 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>湛卢代理登录</title>
|
<title>湛卢代理登录</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
:root{
|
||||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #dde7ff, transparent 34rem), linear-gradient(135deg, #101828, #1f2937); color: #e5e7eb; }
|
color-scheme:light;
|
||||||
main { width: min(760px, calc(100vw - 32px)); display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: stretch; }
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||||
.hero, form { border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
|
||||||
.hero { padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
|
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
||||||
h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.04em; }
|
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
|
||||||
p { color: #b6c2d9; line-height: 1.7; }
|
--ok:#059669; --err:#dc2626;
|
||||||
code { color: #bfdbfe; word-break: break-all; }
|
--radius:16px; --radius-sm:10px;
|
||||||
.login-card { padding: 28px; display: grid; gap: 16px; border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
}
|
||||||
label { display: grid; gap: 8px; font-size: 14px; color: #cbd5e1; }
|
*{box-sizing:border-box}
|
||||||
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(148,163,184,.35); border-radius: 14px; padding: 12px 14px; background: rgba(2,6,23,.55); color: #f8fafc; outline: none; font: inherit; }
|
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
||||||
input:focus, textarea:focus { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(96,165,250,.16); }
|
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
||||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
.login-button { display: block; text-align: center; text-decoration: none; border: 0; border-radius: 14px; padding: 14px 16px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; font-weight: 700; cursor: pointer; font: inherit; }
|
.card{width:min(440px,100%);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:40px 36px}
|
||||||
.login-button:hover { filter: brightness(1.08); }
|
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||||
.status { min-height: 22px; color: #93c5fd; }
|
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
|
||||||
.muted { font-size: 13px; color: #94a3b8; }
|
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
||||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; padding: 18px 0; } .row { grid-template-columns: 1fr; } }
|
h1{margin:0 0 10px;font-size:24px;font-weight:700;letter-spacing:-.02em}
|
||||||
|
.lede{margin:0 0 28px;color:var(--body);font-size:14px;line-height:1.6}
|
||||||
|
.lede code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--ink);background:var(--bg);padding:1px 6px;border-radius:5px;border:1px solid var(--border)}
|
||||||
|
form{display:grid;gap:18px}
|
||||||
|
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
|
||||||
|
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
|
||||||
|
input::placeholder{color:var(--muted)}
|
||||||
|
input:hover{border-color:#bdc2cc}
|
||||||
|
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:11px 18px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;text-decoration:none;color:#fff;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
|
||||||
|
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.btn-primary{background:var(--accent);width:100%}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover)}
|
||||||
|
.btn-primary:active{transform:translateY(1px)}
|
||||||
|
.btn-primary:disabled{background:#c2c9d4;cursor:not-allowed}
|
||||||
|
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
|
||||||
|
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
|
||||||
|
.status[data-state="ok"]{color:var(--ok)}
|
||||||
|
.status[data-state="err"]{color:var(--err)}
|
||||||
|
.status[data-state="busy"]{color:var(--accent)}
|
||||||
|
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
||||||
|
.footer{margin-top:24px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
|
||||||
|
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main class="card">
|
||||||
<section class="hero">
|
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
|
||||||
<div>
|
|
||||||
<h1>湛卢代理登录</h1>
|
<h1>湛卢代理登录</h1>
|
||||||
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
|
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
|
||||||
</div>
|
|
||||||
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
|
|
||||||
</section>
|
|
||||||
<section class="login-card">
|
|
||||||
{{if not .AdminMode}}
|
|
||||||
<h2>管理登录</h2>
|
|
||||||
<p>请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码。</p>
|
|
||||||
<form id="password-form">
|
<form id="password-form">
|
||||||
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
|
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
|
||||||
<button class="login-button" type="submit">进入登录管理</button>
|
<button class="btn btn-primary" type="submit">进入管理后台</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="status" id="status">需要登录后才能管理湛卢凭据。</div>
|
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
|
||||||
{{else}}
|
</main>
|
||||||
<h2>手机号验证码登录</h2>
|
<script>
|
||||||
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
|
const statusEl = document.getElementById('status');
|
||||||
|
const passwordForm = document.getElementById('password-form');
|
||||||
|
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
||||||
|
if (passwordForm) {
|
||||||
|
passwordForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const password = passwordForm.password.value;
|
||||||
|
if (!password) { setStatus('请输入登录密码', 'err'); return; }
|
||||||
|
setStatus('正在登录...', 'busy');
|
||||||
|
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
|
||||||
|
window.location.href = '/admin';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>湛卢登录结果</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
color-scheme:light;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||||
|
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
|
||||||
|
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
||||||
|
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
||||||
|
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
||||||
|
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
|
||||||
|
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
|
||||||
|
.mark svg{width:26px;height:26px}
|
||||||
|
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
|
||||||
|
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
|
||||||
|
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
|
||||||
|
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
|
||||||
|
.btn:hover{background:var(--accent-hover)}
|
||||||
|
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
|
||||||
|
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
|
||||||
|
<p>{{.Message}}</p>
|
||||||
|
<a class="btn" href="/admin">返回管理后台</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
||||||
|
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
|
||||||
|
"rate": func(cached, prompt int64) string {
|
||||||
|
if prompt <= 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
|
||||||
|
},
|
||||||
|
"human": humanNum,
|
||||||
|
}).Parse(`<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>湛卢代理管理后台</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
color-scheme:light;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||||
|
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
|
||||||
|
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
||||||
|
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
|
||||||
|
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
|
||||||
|
--radius:16px; --radius-sm:10px;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
||||||
|
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
||||||
|
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
|
||||||
|
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px}
|
||||||
|
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
|
||||||
|
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
|
||||||
|
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
|
||||||
|
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.btn-primary{background:var(--accent);color:#fff}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover)}
|
||||||
|
.btn-primary:active{transform:translateY(1px)}
|
||||||
|
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
|
||||||
|
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
||||||
|
.btn-ghost:active{transform:translateY(1px)}
|
||||||
|
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
|
||||||
|
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
|
||||||
|
.tab:hover{color:var(--ink)}
|
||||||
|
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
|
||||||
|
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
||||||
|
.tabpanel{display:none;min-width:0}
|
||||||
|
.tabpanel.active{display:block;min-width:0}
|
||||||
|
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
|
||||||
|
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
|
||||||
|
.panel.disabled{background:#eef0f3}
|
||||||
|
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||||
|
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
|
||||||
|
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
|
||||||
|
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
|
||||||
|
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||||
|
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||||
|
tbody tr:last-child td{border-bottom:0}
|
||||||
|
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
|
||||||
|
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
|
||||||
|
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
|
||||||
|
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
|
||||||
|
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
|
||||||
|
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
|
||||||
|
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
|
||||||
|
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
|
||||||
|
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
|
||||||
|
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
|
||||||
|
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.muted{color:var(--muted);font-size:13px;line-height:1.6}
|
||||||
|
.scroll{overflow-x:auto;min-width:0}
|
||||||
|
.empty{color:var(--muted);font-size:13px;padding:8px 0}
|
||||||
|
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
|
||||||
|
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
|
||||||
|
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
||||||
|
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
|
.page-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
|
||||||
|
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
|
||||||
|
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
|
||||||
|
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
|
||||||
|
form{display:grid;gap:18px}
|
||||||
|
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
|
||||||
|
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
|
||||||
|
input::placeholder{color:var(--muted)}
|
||||||
|
input:hover{border-color:#bdc2cc}
|
||||||
|
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||||
|
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
|
||||||
|
.btn-primary.full{width:100%}
|
||||||
|
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
|
||||||
|
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
|
||||||
|
.status[data-state="ok"]{color:var(--ok)}
|
||||||
|
.status[data-state="err"]{color:var(--err)}
|
||||||
|
.status[data-state="busy"]{color:var(--accent)}
|
||||||
|
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
|
||||||
|
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
||||||
|
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
|
||||||
|
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
|
||||||
|
<h1>湛卢代理管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
||||||
|
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="tabpanel active" data-tab="stats" role="tabpanel">
|
||||||
|
<div class="sub-actions">
|
||||||
|
<button class="btn btn-ghost" id="refresh">刷新</button>
|
||||||
|
<button class="btn btn-primary" id="reset">重置统计</button>
|
||||||
|
</div>
|
||||||
|
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
|
||||||
|
<div class="panel">
|
||||||
|
<h2>总览</h2>
|
||||||
|
<div class="grid4">
|
||||||
|
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
|
||||||
|
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
|
||||||
|
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
|
||||||
|
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
|
||||||
|
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-top:20px">
|
||||||
|
<h2>按模型</h2>
|
||||||
|
{{if .Stats.PerModel}}
|
||||||
|
<div class="scroll"><table>
|
||||||
|
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{{else}}<p class="empty">暂无数据</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-top:20px">
|
||||||
|
<h2>按日</h2>
|
||||||
|
{{if .Stats.Daily}}
|
||||||
|
<div id="daily">
|
||||||
|
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="empty">暂无数据</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-top:20px">
|
||||||
|
<h2>最近请求</h2>
|
||||||
|
{{if .Stats.Recent}}
|
||||||
|
<div class="scroll"><table id="recent">
|
||||||
|
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
<div class="pager" id="recent-pager"></div>
|
||||||
|
{{else}}<p class="empty">暂无数据</p>{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tabpanel" data-tab="login" role="tabpanel">
|
||||||
|
<div class="login-card">
|
||||||
|
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
||||||
<form id="phone-form">
|
<form id="phone-form">
|
||||||
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
|
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
|
||||||
<label>验证码
|
<label>验证码
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
|
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
|
||||||
<button class="login-button" id="code-button" type="button">获取验证码</button>
|
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button class="login-button" type="submit">登录并保存凭据</button>
|
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="status" id="status">正在检查登录状态...</div>
|
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
|
||||||
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button id="logout-button" type="button">退出管理登录</button>{{end}}</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const statusEl = document.getElementById('status');
|
(function () {
|
||||||
const form = document.getElementById('phone-form');
|
var tabs = document.querySelectorAll('.tab');
|
||||||
const passwordForm = document.getElementById('password-form');
|
var panels = document.querySelectorAll('.tabpanel');
|
||||||
const codeButton = document.getElementById('code-button');
|
function activate(name) {
|
||||||
const logoutButton = document.getElementById('logout-button');
|
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
|
||||||
let secret = '';
|
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
|
||||||
let countdown = 0;
|
|
||||||
let countdownTimer = null;
|
|
||||||
|
|
||||||
function setStatus(text) {
|
|
||||||
statusEl.textContent = text;
|
|
||||||
}
|
}
|
||||||
|
tabs.forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
var name = tab.dataset.tab;
|
||||||
|
activate(name);
|
||||||
|
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var hash = location.hash.replace('#', '');
|
||||||
|
if (hash === 'login') activate('login');
|
||||||
|
|
||||||
|
var rows = document.querySelectorAll('#daily .bar');
|
||||||
|
var max = 1;
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
|
||||||
|
if (t > max) max = t;
|
||||||
|
}
|
||||||
|
for (var j = 0; j < rows.length; j++) {
|
||||||
|
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
|
||||||
|
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
|
||||||
|
}
|
||||||
|
var refresh = document.getElementById('refresh');
|
||||||
|
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
|
||||||
|
var reset = document.getElementById('reset');
|
||||||
|
if (reset) reset.addEventListener('click', function () {
|
||||||
|
if (!confirm('确定清空所有统计数据?')) return;
|
||||||
|
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// paginate the recent-requests table (data is already rendered server-side)
|
||||||
|
(function () {
|
||||||
|
var tbody = document.querySelector('#recent tbody');
|
||||||
|
var pager = document.getElementById('recent-pager');
|
||||||
|
if (!tbody || !pager) return;
|
||||||
|
var rows = tbody.querySelectorAll('tr');
|
||||||
|
if (rows.length === 0) { pager.style.display = 'none'; return; }
|
||||||
|
var pageSize = 10;
|
||||||
|
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||||
|
var page = 1;
|
||||||
|
function pageList(c, t) {
|
||||||
|
var p = [];
|
||||||
|
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
|
||||||
|
p.push(1);
|
||||||
|
if (c > 3) p.push('…');
|
||||||
|
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
|
||||||
|
for (var m = s; m <= e; m++) p.push(m);
|
||||||
|
if (c < t - 2) p.push('…');
|
||||||
|
p.push(t);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
var start = (page - 1) * pageSize;
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
|
||||||
|
}
|
||||||
|
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '>‹</button>';
|
||||||
|
var list = pageList(page, totalPages);
|
||||||
|
for (var n = 0; n < list.length; n++) {
|
||||||
|
var item = list[n];
|
||||||
|
if (item === '…') html += '<span class="page-btn dots">…</span>';
|
||||||
|
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
|
||||||
|
}
|
||||||
|
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
|
||||||
|
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
|
||||||
|
pager.innerHTML = html;
|
||||||
|
}
|
||||||
|
pager.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('.page-btn');
|
||||||
|
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
|
||||||
|
if (btn.dataset.act === 'prev' && page > 1) page--;
|
||||||
|
else if (btn.dataset.act === 'next' && page < totalPages) page++;
|
||||||
|
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
|
||||||
|
var logout = document.getElementById('logout-button');
|
||||||
|
if (logout) logout.addEventListener('click', async function () {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var statusEl = document.getElementById('status');
|
||||||
|
var form = document.getElementById('phone-form');
|
||||||
|
var codeButton = document.getElementById('code-button');
|
||||||
|
var secret = '';
|
||||||
|
var countdown = 0;
|
||||||
|
var countdownTimer = null;
|
||||||
|
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
countdown = 60;
|
countdown = 60;
|
||||||
codeButton.disabled = true;
|
codeButton.disabled = true;
|
||||||
countdownTimer && clearInterval(countdownTimer);
|
countdownTimer && clearInterval(countdownTimer);
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(function () {
|
||||||
if (countdown <= 0) {
|
if (countdown <= 0) {
|
||||||
clearInterval(countdownTimer);
|
clearInterval(countdownTimer);
|
||||||
codeButton.disabled = false;
|
codeButton.disabled = false;
|
||||||
@@ -936,93 +1445,36 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|||||||
countdown--;
|
countdown--;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordForm) {
|
|
||||||
passwordForm.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const password = passwordForm.password.value;
|
|
||||||
if (!password) {
|
|
||||||
setStatus('请输入登录密码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('正在登录...');
|
|
||||||
const res = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.ok) {
|
|
||||||
setStatus(data.error || '登录失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/admin/login';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logoutButton) {
|
|
||||||
logoutButton.addEventListener('click', async () => {
|
|
||||||
await fetch('/api/logout', { method: 'POST' });
|
|
||||||
window.location.href = '/login';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
fetch('/api/credentials').then(r => r.json()).then(data => {
|
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
|
||||||
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
||||||
|
statusEl.dataset.state = data.configured ? 'ok' : '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (codeButton) codeButton.addEventListener('click', async function () {
|
||||||
codeButton && codeButton.addEventListener('click', async () => {
|
var telephone = form.telephone.value.trim();
|
||||||
const telephone = form.telephone.value.trim();
|
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
|
||||||
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
|
||||||
setStatus('请输入有效的 11 位手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
codeButton.disabled = true;
|
codeButton.disabled = true;
|
||||||
setStatus('正在发送验证码...');
|
setStatus('正在发送验证码...', 'busy');
|
||||||
const res = await fetch('/api/auth/code', {
|
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
|
||||||
method: 'POST',
|
var data = await res.json();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
|
||||||
body: JSON.stringify({ telephone })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.ok) {
|
|
||||||
codeButton.disabled = false;
|
|
||||||
setStatus(data.error || '验证码发送失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
secret = data.secret;
|
secret = data.secret;
|
||||||
setStatus('验证码已发送');
|
setStatus('验证码已发送', 'ok');
|
||||||
startCountdown();
|
startCountdown();
|
||||||
});
|
});
|
||||||
|
if (form) form.addEventListener('submit', async function (event) {
|
||||||
form && form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const telephone = form.telephone.value.trim();
|
var telephone = form.telephone.value.trim();
|
||||||
const code = form.code.value.trim();
|
var code = form.code.value.trim();
|
||||||
if (!secret) {
|
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
|
||||||
setStatus('请先获取验证码');
|
setStatus('正在登录并保存凭据...', 'busy');
|
||||||
return;
|
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
|
||||||
}
|
var data = await res.json();
|
||||||
setStatus('正在登录并保存凭据...');
|
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
|
||||||
const res = await fetch('/api/auth/login', {
|
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ telephone, code, secret })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || !data.ok) {
|
|
||||||
setStatus(data.error || '登录失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';JSON:' + (data.path || ''));
|
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`))
|
</html>`))
|
||||||
|
|
||||||
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
|
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>湛卢登录结果</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font-family:system-ui}.card{max-width:560px;margin:24px;padding:32px;border:1px solid #334155;border-radius:22px;background:#1e293b;text-align:center}a{color:#93c5fd}</style></head>
|
|
||||||
<body><main class="card">{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}<p>{{.Message}}</p><a href="/login">返回登录页</a></main></body></html>`))
|
|
||||||
|
|||||||
+102
-13
@@ -15,12 +15,15 @@ import (
|
|||||||
|
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
|
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
|
||||||
|
|
||||||
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to it.
|
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to
|
||||||
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string) {
|
// 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()
|
t.Helper()
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
@@ -47,7 +50,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":1}}\n\n")
|
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"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: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
|
||||||
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
case "/gateway/v1/model/info":
|
case "/gateway/v1/model/info":
|
||||||
@@ -59,28 +62,32 @@ func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string)
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
credsFile := filepath.Join(t.TempDir(), "credentials.json")
|
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{
|
cfg := config.Config{
|
||||||
ListenAddr: ":0",
|
ListenAddr: ":0",
|
||||||
MobileLoginBaseURL: upstream.URL,
|
MobileLoginBaseURL: upstream.URL,
|
||||||
MobileModelBaseURL: upstream.URL,
|
MobileModelBaseURL: upstream.URL,
|
||||||
UpstreamPath: "/chat/completions",
|
UpstreamPath: "/chat/completions",
|
||||||
CredentialsPath: credsFile,
|
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
|
||||||
TokenDecryptKey: "3jw7woww2rvhla6k",
|
TokenDecryptKey: "3jw7woww2rvhla6k",
|
||||||
PublicKeyPEM: defaultTestPublicKey,
|
PublicKeyPEM: defaultTestPublicKey,
|
||||||
PhonePublicKeyPEM: defaultTestPublicKey,
|
PhonePublicKeyPEM: defaultTestPublicKey,
|
||||||
SM2PrivateKey: testSM2Key,
|
SM2PrivateKey: testSM2Key,
|
||||||
PluginVersion: "1.4.2",
|
PluginVersion: "1.4.2",
|
||||||
}
|
}
|
||||||
h := New(cfg)
|
h := New(cfg, st)
|
||||||
proxy := httptest.NewServer(h)
|
proxy := httptest.NewServer(h)
|
||||||
return upstream, proxy, credsFile
|
return upstream, proxy, st
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
|
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
|
||||||
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
|
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
|
||||||
func TestPhoneLoginAndChat(t *testing.T) {
|
func TestPhoneLoginAndChat(t *testing.T) {
|
||||||
upstream, proxy, credsFile := setupTestServer(t)
|
upstream, proxy, st := setupTestServer(t)
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
defer proxy.Close()
|
defer proxy.Close()
|
||||||
|
|
||||||
@@ -107,8 +114,8 @@ func TestPhoneLoginAndChat(t *testing.T) {
|
|||||||
t.Fatalf("login failed: %v", loginResp)
|
t.Fatalf("login failed: %v", loginResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. credentials file should contain the provisioned api key
|
// 3. credentials store should contain the provisioned api key
|
||||||
creds, err := auth.LoadCredentials(credsFile)
|
creds, err := st.LoadCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -154,11 +161,11 @@ func TestPhoneLoginAndChat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamingChat(t *testing.T) {
|
func TestStreamingChat(t *testing.T) {
|
||||||
upstream, proxy, credsFile := setupTestServer(t)
|
upstream, proxy, st := setupTestServer(t)
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
defer proxy.Close()
|
defer proxy.Close()
|
||||||
|
|
||||||
// Seed credentials directly with the api key
|
// Seed credentials directly with the api key into the store
|
||||||
creds := auth.Credentials{
|
creds := auth.Credentials{
|
||||||
AccessKey: "AK",
|
AccessKey: "AK",
|
||||||
SecretKey: "SK",
|
SecretKey: "SK",
|
||||||
@@ -167,7 +174,7 @@ func TestStreamingChat(t *testing.T) {
|
|||||||
ModelBaseURL: upstream.URL,
|
ModelBaseURL: upstream.URL,
|
||||||
Email: "[email protected]",
|
Email: "[email protected]",
|
||||||
}
|
}
|
||||||
if err := auth.SaveCredentials(credsFile, creds); err != nil {
|
if err := st.SaveCredentials(creds); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +195,88 @@ func TestStreamingChat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func okValue(m map[string]any) bool {
|
||||||
ok, _ := m["ok"].(bool)
|
ok, _ := m["ok"].(bool)
|
||||||
return ok
|
return ok
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user