7 Commits
Author SHA1 Message Date
m1saka a0a2049440 Add 1d/7d/all time-range filter to admin by-model stats
build / build (push) Successful in 2m32s
By-model panel gains a segmented 1天/7天/全部 control. Selecting a
range fetches /api/stats?since=<RFC3339> and re-renders only that table
client-side, replicating the human/rate template helpers in JS; the
existing /api/stats since param already drives the server-side filter.

- internal/server/templates/admin.html: filter UI + JS, empty/loading states
- internal/server/server_test.go: render test for the filter controls
- scripts/test-instance.sh: build→start→stop→clean test-instance helper
2026-08-20 17:08:39 +08:00
m1saka 9aba511abe Refactor server: extract shared helpers, fix tool-call ordering and finish_reason mapping
- Extract callUpstream helper to deduplicate ~30 lines between chatCompletions and responses
- Move HTML templates to internal/server/templates/ via go:embed (server.go 1749→1192 lines)
- Consolidate 4 copies of firstNonEmpty into util.FirstNonEmpty
- Extract chatStreamChunk named type shared by aggregateStream and aggregateResponsesStream
- Fix tool-call ordering: iterate sorted map keys instead of sequential 0..N
- Map upstream finish_reason to Responses API status (length/content_filter → incomplete)
- Surface /v1/models errors as 401/502 instead of silently returning empty 200
- Add Secure cookie flag via isTLSRequest helper
- forEachSSEChunk: use sseDataPayload parser, drop redundant json.Valid, distinguish bufio.ErrTooLong
- Fix StreamIdleTimout typo → StreamIdleTimeout
- sso.go: single Read → io.ReadAll(io.LimitReader), explicit unknown error fallback
2026-08-20 15:46:47 +08:00
m1saka 2517b4f730 Add OpenAI Responses API (/v1/responses) endpoint
Translate Responses API requests (input→messages, instructions→system,
max_output_tokens→max_tokens, text.format→response_format, flat tools→nested
{function:{…}}) to upstream chat/completions, then convert responses back to
Responses format (streaming SSE event lifecycle + non-streaming JSON).

Verified against OpenAI migration guide and Python SDK Response model:
- Echo back required fields parallel_tool_calls/tool_choice/tools
- Include content:[] in reasoning items, logprobs:[] in output_text parts
- Support function_call/function_call_output multi-turn input items
- Map usage fields prompt_tokens→input_tokens, completion_tokens→output_tokens
2026-08-20 10:02:11 +08:00
m1saka c8938cb514 Add available-models admin tab with live model list and latency probing
build / build (push) Successful in 2m29s
2026-08-19 20:23:21 +08:00
root 8a1cf4d61f Redesign admin UI: unified tabbed console, humanized and paginated stats
build / build (push) Successful in 2m32s
- Unify /admin/login and /admin/stats into a single /admin page with tabs
  (Token stats default, credential login); old paths redirect to /admin.
- Restyle from dark glass-morphism to a clean light theme (single accent,
  system font stack, 1px-bordered cards, generous whitespace).
- Humanize token counts with K/M/B suffixes via a humanNum template func.
- Paginate recent requests client-side (10 per page) with a compact pager.
- Drop the database path from the UI.
- Fix mobile horizontal overflow caused by grid min-width:auto.
2026-08-19 17:12:30 +08:00
m1saka fa9640d919 Add token usage stats and consolidate credentials in SQLite
build / build (push) Successful in 2m34s
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder.
- Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate.
- Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
2026-08-19 15:52:00 +08:00
root dd5c560b64 Build Windows release binaries
build / build (push) Successful in 1m52s
2026-08-14 15:48:52 +08:00
28 changed files with 3834 additions and 606 deletions
+11 -5
View File
@@ -58,8 +58,13 @@ jobs:
CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-o "zhanlu-proxy-linux-${arch}" ./cmd/zhanlu-proxy
echo "building windows/${arch}"
CGO_ENABLED=0 GOOS=windows GOARCH="${arch}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-o "zhanlu-proxy-windows-${arch}.exe" ./cmd/zhanlu-proxy
done
ls -lh zhanlu-proxy-linux-*
ls -lh zhanlu-proxy-linux-* zhanlu-proxy-windows-*.exe
- name: Publish release assets
if: startsWith(github.ref, 'refs/tags/v')
@@ -80,10 +85,11 @@ jobs:
echo "release id: ${RELEASE_ID}"
for arch in amd64 arm64; do
f="zhanlu-proxy-linux-${arch}"
curl -fsSL -X POST -H "${AUTH}" \
-F "attachment=@${f};filename=${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${f}"
for f in "zhanlu-proxy-linux-${arch}" "zhanlu-proxy-windows-${arch}.exe"; do
curl -fsSL -X POST -H "${AUTH}" \
-F "attachment=@${f};filename=${f}" \
"${API}/releases/${RELEASE_ID}/assets?name=${f}"
done
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -1
View File
@@ -1,4 +1,9 @@
credentials.json
zhanlu.db
zhanlu.db-shm
zhanlu.db-wal
*.db
*.db-shm
*.db-wal
extension/
*.exe
*.log
@@ -8,3 +13,4 @@ extension/
tmp/
temp/
source/
output/
+77 -13
View File
@@ -5,11 +5,13 @@
当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions`)。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions``/v1/responses`Responses API,自动转换请求/响应格式,兼容新版 SDK 与 AI 编码工具)
- 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证。
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
- OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
- 可用模型:管理后台新增「可用模型」tab,实时拉取上游 `/gateway/v1/model/info` 返回的模型列表,并提供单模型可用性及首字延时(TTFT)探测(`GET /api/models``POST /api/models/test`)。
## 运行
@@ -37,17 +39,17 @@ http://127.0.0.1:8080/
/opt/zhanlu-proxy/zhanlu-proxy
```
凭据文件放在:
数据库放在:
```text
/opt/zhanlu-proxy/credentials.json
/opt/zhanlu-proxy/zhanlu.db
```
创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env`
```env
ZHANLU_LISTEN_ADDR=:8080
ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json
ZHANLU_DB_FILE=/opt/zhanlu-proxy/zhanlu.db
ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
ZHANLU_UPSTREAM_TIMEOUT=300s
@@ -106,20 +108,22 @@ journalctl -u zhanlu-proxy -f
- 使用本次 `secret` AES 解密响应中的 `ak``sk``license`,得到 `AccessKey``SecretKey``Token`
- 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)。
- 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`
- 凭据(含 `apiKey``modelBaseUrl`、email 等)会写入 JSON 文件,后续 OpenAI 兼容接口自动使用。
- 凭据(含 `apiKey``modelBaseUrl`、email 等)会写入本地 SQLite 数据库(`zhanlu.db`,后续 OpenAI 兼容接口自动使用。
默认保存当前执行目录:
默认数据库保存当前执行目录:
```text
credentials.json
zhanlu.db
```
可以通过环境变量覆盖:
```powershell
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json"
$env:ZHANLU_DB_FILE="E:\path\to\zhanlu.db"
```
凭据仅持久化在数据库中,不再使用 JSON 文件。
手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`):
```powershell
@@ -165,6 +169,30 @@ curl -N http://127.0.0.1:8080/v1/chat/completions `
请求中的 `tools``tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls``role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。
### Responses API
代理还兼容 OpenAI Responses API`POST /v1/responses`),支持使用该 API 的客户端(如新版 OpenAI SDK、Cursor 等 AI 编码工具)直接对接。代理会将 Responses API 请求格式转换为上游 chat/completions 格式,再将响应转回 Responses 格式。
请求中的 `input`(字符串或消息数组)、`instructions`(系统提示词)、`max_output_tokens``temperature``top_p``tools``tool_choice``response_format` 等参数均会翻译并透传给上游。`tools` 格式自动从 Responses 扁平结构转换为 Chat Completions 嵌套 `{function:{…}}` 结构。多轮对话中的 `function_call``function_call_output` 输入项也会自动转换为对应的 assistant `tool_calls``role: tool` 消息。
非流式:
```powershell
curl http://127.0.0.1:8080/v1/responses `
-H "Content-Type: application/json" `
-d '{"model":"zhanlu/auto","input":"hello","stream":false}'
```
流式:
```powershell
curl -N http://127.0.0.1:8080/v1/responses `
-H "Content-Type: application/json" `
-d '{"model":"zhanlu/auto","input":"hello","stream":true}'
```
流式响应返回标准 Responses API SSE 事件序列(`response.created``response.output_item.added``response.output_text.delta``response.output_text.done``response.output_item.done``response.completed`),思考模型额外包含 `response.reasoning_summary_text.delta` 事件,工具调用包含 `response.function_call_arguments.delta` 事件。Token 统计同样记录在 SQLite 中。
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`
```powershell
@@ -184,7 +212,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_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL |
| `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_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
@@ -207,14 +236,49 @@ curl http://127.0.0.1:8080/v1/models `
服务启动时按以下优先级加载凭据:
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` 可停止写入统计。
## 可用模型
管理后台「可用模型」tab(位于「Token 统计」之后)实时展示当前上游接口返回的模型列表,并提供单模型可用性与延时探测:
- 进入 tab 时自动拉取一次,也可随时点击「刷新」重新获取。
- 每个模型行可单独「测试」,或点击「全部测试」依次探测所有模型。
- 探测向上游 `/chat/completions` 发送一条极简流式请求(`hi`),测量首字延时(TTFT,首个 SSE 数据块到达时间)与总耗时,并在收到首个内容块后立即关闭连接,避免消耗额外 token。
- 探测请求不计入 Token 统计。
对应 JSON 接口(需先登录管理页面):
- `GET /api/models`:实时返回上游模型列表 `{"ok":true,"models":["GLM-4.7",...]}`
- `POST /api/models/test`:请求体 `{"model":"<model_id>"}`,返回 `{"ok":true,"model":"...","available":true,"ttft_ms":123,"total_ms":456}``{"ok":true,"model":"...","available":false,"error":"...","total_ms":789}`
探测超时上限为 30 秒(`modelTestTimeout`);凭据未配置或 API Key 缺失时 `GET /api/models``POST /api/models/test` 会按需自动换取 API Key,与聊天接口一致。
## 安全说明
- `credentials.json` 包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json`
- `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `zhanlu.db`(建议通过 `ZHANLU_DB_FILE` 指向受保护路径)
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature`
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
func main() {
@@ -14,7 +15,20 @@ func main() {
log.Fatal(err)
}
h := server.New(cfg)
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer st.Close()
// Credential precedence at startup: environment variables > db row.
if cfg.Credentials.Validate() != nil && !cfg.Credentials.HasAPIKey() {
if dbCreds, derr := st.LoadCredentials(); derr == nil && (dbCreds.HasAPIKey() || dbCreds.Validate() == nil) {
cfg.Credentials = dbCreds
}
}
h := server.New(cfg, st)
log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr)
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
+13 -1
View File
@@ -4,4 +4,16 @@ go 1.25.0
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
)
+18
View File
@@ -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/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
-30
View File
@@ -1,10 +1,7 @@
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
@@ -49,30 +46,3 @@ func (c Credentials) Validate() error {
func (c Credentials) HasAPIKey() bool {
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
}
func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path)
if err != nil {
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)
}
+13 -14
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
@@ -39,9 +42,8 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b := make([]byte, 1024)
n, _ := resp.Body.Read(b)
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var exchange ExchangeResponse
@@ -49,11 +51,17 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
return Profile{}, err
}
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
if exchange.State != "" && exchange.State != "OK" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
@@ -95,12 +103,3 @@ type ExchangeResponse struct {
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return "unknown error"
}
+10 -25
View File
@@ -2,11 +2,11 @@ package config
import (
"os"
"path/filepath"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Config struct {
@@ -14,7 +14,8 @@ type Config struct {
MobileLoginBaseURL string
MobileModelBaseURL string
UpstreamPath string
CredentialsPath string
DBPath string
StatsDisabled bool
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
@@ -25,7 +26,7 @@ type Config struct {
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
StreamIdleTimeout time.Duration
Debug bool
Credentials auth.Credentials
}
@@ -33,10 +34,11 @@ type Config struct {
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileLoginBaseURL: util.FirstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
StatsDisabled: strings.EqualFold(os.Getenv("ZHANLU_STATS_DISABLED"), "true"),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"),
TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
@@ -47,7 +49,7 @@ func Load() (Config, error) {
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Credentials = auth.Credentials{
@@ -56,12 +58,8 @@ func Load() (Config, error) {
Token: os.Getenv("ZHANLU_TOKEN"),
APIKey: os.Getenv("ZHANLU_API_KEY"),
}
if cfg.Credentials.Validate() == nil || cfg.Credentials.HasAPIKey() {
return cfg, nil
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
cfg.Credentials = creds
}
// Credentials from the environment were incomplete; the store serves the
// persisted row at runtime.
return cfg, nil
}
@@ -84,19 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func defaultCredentialsPath() string {
return filepath.Join(".", "credentials.json")
}
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+347
View File
@@ -0,0 +1,347 @@
package openai
import (
"encoding/json"
"errors"
)
// --- Responses API request parsing & conversion ---
// ParseResponsesRequest converts a raw OpenAI Responses API (POST /v1/responses)
// request body into a ChatCompletionRequest suitable for the upstream gateway.
// It translates input→messages, instructions→system message, max_output_tokens→
// max_tokens, and reshapes tools from the Responses flat format to the Chat
// Completions nested {function:{…}} format.
func ParseResponsesRequest(body []byte) (ChatCompletionRequest, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return ChatCompletionRequest{}, err
}
var req ChatCompletionRequest
if v, ok := raw["model"]; ok {
_ = json.Unmarshal(v, &req.Model)
}
if v, ok := raw["stream"]; ok {
_ = json.Unmarshal(v, &req.Stream)
}
// Build messages from instructions + input.
messages := make([]map[string]any, 0, 4)
if v, ok := raw["instructions"]; ok {
var s string
if json.Unmarshal(v, &s) == nil && s != "" {
messages = append(messages, map[string]any{"role": "system", "content": s})
}
}
if v, ok := raw["input"]; ok {
msgs, err := responsesInputToMessages(v)
if err != nil {
return req, err
}
messages = append(messages, msgs...)
}
req.Messages = messages
// Extra: renamed + pass-through fields sent to upstream as-is.
extra := map[string]json.RawMessage{}
if v, ok := raw["max_output_tokens"]; ok {
extra["max_tokens"] = v
}
for _, key := range []string{
"temperature", "top_p", "top_k", "frequency_penalty",
"presence_penalty", "stop", "seed", "user",
} {
if v, ok := raw[key]; ok {
extra[key] = v
}
}
if v, ok := raw["tools"]; ok {
if translated, err := translateResponsesTools(v); err == nil {
extra["tools"] = translated
} else {
extra["tools"] = v // fall back to pass-through
}
}
if v, ok := raw["tool_choice"]; ok {
extra["tool_choice"] = v
}
// Structured Outputs: Responses API uses text.format instead of
// response_format. Translate text.format → response_format for the
// upstream Chat Completions endpoint.
if v, ok := raw["text"]; ok {
if rf, err := translateTextFormat(v); err == nil {
extra["response_format"] = rf
}
}
req.Extra = extra
return req, nil
}
// translateTextFormat converts the Responses API "text" field (containing a
// "format" sub-object) into a Chat Completions response_format value.
// - {text:{format:{type:"json_object"}}} → {type:"json_object"}
// - {text:{format:{type:"json_schema",name,schema,strict}}} →
// {type:"json_schema",json_schema:{name,schema,strict}}
func translateTextFormat(textRaw json.RawMessage) (json.RawMessage, error) {
var text struct {
Format map[string]any `json:"format"`
}
if err := json.Unmarshal(textRaw, &text); err != nil {
return nil, err
}
if len(text.Format) == 0 {
return nil, errors.New("empty text.format")
}
t, _ := text.Format["type"].(string)
switch t {
case "json_object":
return json.Marshal(map[string]any{"type": "json_object"})
case "json_schema":
js := map[string]any{}
for _, k := range []string{"name", "schema", "strict"} {
if v, ok := text.Format[k]; ok {
js[k] = v
}
}
return json.Marshal(map[string]any{"type": "json_schema", "json_schema": js})
default:
return json.Marshal(text.Format) // pass through unknown types
}
}
// ResponsesMeta holds fields from the Responses API request that should be
// echoed back in the response object (the OpenAI SDK requires them).
type ResponsesMeta struct {
ParallelToolCalls any
ToolChoice any
Tools any
Temperature any
TopP any
MaxOutputTokens any
}
// ParseResponsesMeta extracts echo-back fields from the raw request body.
func ParseResponsesMeta(body []byte) ResponsesMeta {
var m map[string]any
_ = json.Unmarshal(body, &m)
meta := ResponsesMeta{
ParallelToolCalls: false,
ToolChoice: "auto",
Tools: []any{},
}
if v, ok := m["parallel_tool_calls"]; ok {
meta.ParallelToolCalls = v
}
if v, ok := m["tool_choice"]; ok {
meta.ToolChoice = v
}
// Tools are echoed back in the Responses API flat format (not the
// translated Chat Completions format).
if v, ok := m["tools"]; ok {
meta.Tools = v
} else {
meta.Tools = []any{}
}
if v, ok := m["temperature"]; ok {
meta.Temperature = v
}
if v, ok := m["top_p"]; ok {
meta.TopP = v
}
if v, ok := m["max_output_tokens"]; ok {
meta.MaxOutputTokens = v
}
return meta
}
// responsesInputToMessages converts the Responses API "input" field (which may
// be a plain string or an array of input items) into Chat Completions messages.
func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) {
// Case 1: input is a plain string.
var s string
if json.Unmarshal(input, &s) == nil {
return []map[string]any{{"role": "user", "content": s}}, nil
}
// Case 2: input is an array of items.
var items []map[string]json.RawMessage
if err := json.Unmarshal(input, &items); err != nil {
return nil, err
}
messages := make([]map[string]any, 0, len(items))
for _, item := range items {
// Determine the type — most items are message-like with a role.
var role string
if v, ok := item["role"]; ok {
_ = json.Unmarshal(v, &role)
}
var itemType string
if v, ok := item["type"]; ok {
_ = json.Unmarshal(v, &itemType)
}
switch {
case itemType == "function_call":
// An assistant tool call from a previous turn.
msg := map[string]any{"role": "assistant"}
tc := map[string]any{"type": "function"}
inner := map[string]any{}
if v, ok := item["name"]; ok {
var name string
_ = json.Unmarshal(v, &name)
inner["name"] = name
}
if v, ok := item["arguments"]; ok {
var args string
_ = json.Unmarshal(v, &args)
inner["arguments"] = args
}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
tc["id"] = id
}
tc["function"] = inner
msg["tool_calls"] = []any{tc}
messages = append(messages, msg)
case itemType == "function_call_output":
// A tool result from a previous turn.
msg := map[string]any{"role": "tool"}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
msg["tool_call_id"] = id
}
if v, ok := item["output"]; ok {
var out string
_ = json.Unmarshal(v, &out)
msg["content"] = out
}
messages = append(messages, msg)
default:
// Standard message item with role + content.
if role == "" {
role = "user"
}
// "developer" maps to "system" for broad upstream compatibility.
if role == "developer" {
role = "system"
}
msg := map[string]any{"role": role}
if v, ok := item["content"]; ok {
msg["content"] = convertContentParts(v)
} else {
msg["content"] = ""
}
messages = append(messages, msg)
}
}
return messages, nil
}
// convertContentParts converts a Responses API content field (string or array
// of content parts) into the Chat Completions content format.
func convertContentParts(raw json.RawMessage) any {
// Content is a plain string.
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// Content is an array of parts.
var parts []map[string]any
if json.Unmarshal(raw, &parts) != nil {
return string(raw) // fallback
}
result := make([]map[string]any, 0, len(parts))
for _, p := range parts {
pt, _ := p["type"].(string)
switch pt {
case "input_text", "output_text", "text":
result = append(result, map[string]any{"type": "text", "text": p["text"]})
case "input_image":
img := map[string]any{"type": "image_url"}
if url, ok := p["image_url"]; ok {
img["image_url"] = map[string]any{"url": url}
} else if d, ok := p["image"]; ok {
img["image_url"] = map[string]any{"url": d}
}
result = append(result, img)
default:
// Pass through unknown part types as-is.
result = append(result, p)
}
}
if len(result) == 0 {
return ""
}
return result
}
// translateResponsesTools converts tools from the Responses API flat format
// to the Chat Completions nested {type:"function",function:{…}} format.
func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
var tools []map[string]any
if err := json.Unmarshal(raw, &tools); err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
tt, _ := t["type"].(string)
if tt != "function" && tt != "" {
// Non-function tool types (web_search, file_search, etc.) —
// pass through as-is; upstream may or may not support them.
out = append(out, t)
continue
}
fn := map[string]any{}
for _, key := range []string{"name", "description", "parameters", "strict"} {
if v, ok := t[key]; ok {
fn[key] = v
}
}
out = append(out, map[string]any{"type": "function", "function": fn})
}
return json.Marshal(out)
}
// --- Responses API response building ---
// UsageToResponses converts a Chat Completions usage value (as decoded by
// json.Unmarshal into any) to the Responses API usage field names
// (input_tokens / output_tokens instead of prompt_tokens / completion_tokens).
func UsageToResponses(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return v
}
var m map[string]any
if json.Unmarshal(b, &m) != nil {
return v
}
out := map[string]any{}
if pt, ok := m["prompt_tokens"]; ok {
out["input_tokens"] = pt
}
if ct, ok := m["completion_tokens"]; ok {
out["output_tokens"] = ct
}
if tt, ok := m["total_tokens"]; ok {
out["total_tokens"] = tt
}
if d, ok := m["prompt_tokens_details"]; ok {
out["input_tokens_details"] = d
} else if d, ok := m["input_tokens_details"]; ok {
out["input_tokens_details"] = d
}
if d, ok := m["completion_tokens_details"]; ok {
out["output_tokens_details"] = d
} else if d, ok := m["output_tokens_details"]; ok {
out["output_tokens_details"] = d
}
return out
}
+305
View File
@@ -0,0 +1,305 @@
package openai
import (
"encoding/json"
"testing"
)
func TestParseResponsesRequest_StringInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if req.Model != "GLM-4.7" {
t.Fatalf("model = %q", req.Model)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "user" {
t.Fatalf("role = %v", req.Messages[0]["role"])
}
if req.Messages[0]["content"] != "hello world" {
t.Fatalf("content = %v", req.Messages[0]["content"])
}
}
func TestParseResponsesRequest_Instructions(t *testing.T) {
body := `{"model":"GLM-4.7","instructions":"be helpful","input":"hi"}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "system" {
t.Fatalf("first role = %v", req.Messages[0]["role"])
}
if req.Messages[0]["content"] != "be helpful" {
t.Fatalf("first content = %v", req.Messages[0]["content"])
}
if req.Messages[1]["role"] != "user" {
t.Fatalf("second role = %v", req.Messages[1]["role"])
}
}
func TestParseResponsesRequest_ArrayInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":"hello"},
{"role":"assistant","content":"hi there"},
{"role":"user","content":"how are you?"}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 3 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "user" || req.Messages[0]["content"] != "hello" {
t.Fatalf("msg[0] = %v", req.Messages[0])
}
if req.Messages[1]["role"] != "assistant" || req.Messages[1]["content"] != "hi there" {
t.Fatalf("msg[1] = %v", req.Messages[1])
}
if req.Messages[2]["role"] != "user" || req.Messages[2]["content"] != "how are you?" {
t.Fatalf("msg[2] = %v", req.Messages[2])
}
}
func TestParseResponsesRequest_ContentParts(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":[
{"type":"input_text","text":"describe this"},
{"type":"input_image","image_url":"data:image/png;base64,abc"}
]}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
content, ok := req.Messages[0]["content"].([]map[string]any)
if !ok {
t.Fatalf("content type = %T", req.Messages[0]["content"])
}
if len(content) != 2 {
t.Fatalf("content parts = %d", len(content))
}
if content[0]["type"] != "text" || content[0]["text"] != "describe this" {
t.Fatalf("content[0] = %v", content[0])
}
if content[1]["type"] != "image_url" {
t.Fatalf("content[1] type = %v", content[1])
}
}
func TestParseResponsesRequest_MaxOutputTokens(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","max_output_tokens":500}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if v, ok := req.Extra["max_tokens"]; !ok {
t.Fatal("max_tokens not in Extra")
} else {
var n int
_ = json.Unmarshal(v, &n)
if n != 500 {
t.Fatalf("max_tokens = %d", n)
}
}
}
func TestParseResponsesRequest_Tools(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","tools":[
{"type":"function","name":"get_weather","description":"get weather","parameters":{"type":"object"}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
raw, ok := req.Extra["tools"]
if !ok {
t.Fatal("tools not in Extra")
}
var tools []map[string]any
if err := json.Unmarshal(raw, &tools); err != nil {
t.Fatal(err)
}
if len(tools) != 1 {
t.Fatalf("tools = %d", len(tools))
}
if tools[0]["type"] != "function" {
t.Fatalf("tool type = %v", tools[0]["type"])
}
fn, ok := tools[0]["function"].(map[string]any)
if !ok {
t.Fatalf("function type = %T", tools[0]["function"])
}
if fn["name"] != "get_weather" {
t.Fatalf("function name = %v", fn["name"])
}
}
func TestParseResponsesRequest_FunctionCallInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":"what's the weather?"},
{"type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
{"type":"function_call_output","call_id":"call_123","output":"sunny 72F"}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 3 {
t.Fatalf("messages = %d items", len(req.Messages))
}
// First message is user text
if req.Messages[0]["role"] != "user" {
t.Fatalf("msg[0] role = %v", req.Messages[0]["role"])
}
// Second message is assistant with tool_calls
if req.Messages[1]["role"] != "assistant" {
t.Fatalf("msg[1] role = %v", req.Messages[1]["role"])
}
tcs, ok := req.Messages[1]["tool_calls"].([]any)
if !ok || len(tcs) != 1 {
t.Fatalf("msg[1] tool_calls = %v", req.Messages[1]["tool_calls"])
}
tc, _ := tcs[0].(map[string]any)
if tc["id"] != "call_123" {
t.Fatalf("tool call id = %v", tc["id"])
}
fn, _ := tc["function"].(map[string]any)
if fn["name"] != "get_weather" {
t.Fatalf("function name = %v", fn["name"])
}
// Third message is tool result
if req.Messages[2]["role"] != "tool" {
t.Fatalf("msg[2] role = %v", req.Messages[2]["role"])
}
if req.Messages[2]["tool_call_id"] != "call_123" {
t.Fatalf("msg[2] tool_call_id = %v", req.Messages[2]["tool_call_id"])
}
if req.Messages[2]["content"] != "sunny 72F" {
t.Fatalf("msg[2] content = %v", req.Messages[2]["content"])
}
}
func TestUsageToResponses(t *testing.T) {
usage := map[string]any{
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"prompt_tokens_details": map[string]any{"cached_tokens": 4},
"completion_tokens_details": map[string]any{"reasoning_tokens": 5},
}
result := UsageToResponses(usage)
m, ok := result.(map[string]any)
if !ok {
t.Fatalf("result type = %T", result)
}
if m["input_tokens"] != float64(10) {
t.Fatalf("input_tokens = %v", m["input_tokens"])
}
if m["output_tokens"] != float64(20) {
t.Fatalf("output_tokens = %v", m["output_tokens"])
}
if m["total_tokens"] != float64(30) {
t.Fatalf("total_tokens = %v", m["total_tokens"])
}
if d, ok := m["input_tokens_details"].(map[string]any); !ok || d["cached_tokens"] != float64(4) {
t.Fatalf("input_tokens_details = %v", m["input_tokens_details"])
}
if d, ok := m["output_tokens_details"].(map[string]any); !ok || d["reasoning_tokens"] != float64(5) {
t.Fatalf("output_tokens_details = %v", m["output_tokens_details"])
}
}
func TestParseResponsesRequest_TextFormat(t *testing.T) {
body := `{"model":"GLM-4.7","input":"Jane, 54","text":{"format":{"type":"json_schema","name":"person","strict":true,"schema":{"type":"object"}}}}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
rf, ok := req.Extra["response_format"]
if !ok {
t.Fatal("response_format not in Extra")
}
var m map[string]any
if err := json.Unmarshal(rf, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "json_schema" {
t.Fatalf("type = %v", m["type"])
}
js, ok := m["json_schema"].(map[string]any)
if !ok {
t.Fatalf("json_schema = %v", m["json_schema"])
}
if js["name"] != "person" {
t.Fatalf("name = %v", js["name"])
}
if js["strict"] != true {
t.Fatalf("strict = %v", js["strict"])
}
}
func TestParseResponsesRequest_TextFormatJsonObject(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","text":{"format":{"type":"json_object"}}}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
rf, ok := req.Extra["response_format"]
if !ok {
t.Fatal("response_format not in Extra")
}
var m map[string]any
if err := json.Unmarshal(rf, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "json_object" {
t.Fatalf("type = %v", m["type"])
}
}
func TestParseResponsesMeta_Defaults(t *testing.T) {
meta := ParseResponsesMeta([]byte(`{"model":"GLM-4.7","input":"hi"}`))
if meta.ParallelToolCalls != false {
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
}
if meta.ToolChoice != "auto" {
t.Fatalf("tool_choice = %v", meta.ToolChoice)
}
tools, ok := meta.Tools.([]any)
if !ok || len(tools) != 0 {
t.Fatalf("tools = %v", meta.Tools)
}
}
func TestParseResponsesMeta_EchoBack(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","parallel_tool_calls":true,"tool_choice":"required","tools":[{"type":"function","name":"get_weather"}],"temperature":0.7,"max_output_tokens":500}`
meta := ParseResponsesMeta([]byte(body))
if meta.ParallelToolCalls != true {
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
}
if meta.ToolChoice != "required" {
t.Fatalf("tool_choice = %v", meta.ToolChoice)
}
tools, ok := meta.Tools.([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools = %v", meta.Tools)
}
if meta.Temperature != 0.7 {
t.Fatalf("temperature = %v", meta.Temperature)
}
if meta.MaxOutputTokens != float64(500) {
t.Fatalf("max_output_tokens = %v", meta.MaxOutputTokens)
}
}
+563
View File
@@ -0,0 +1,563 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
)
// responses handles POST /v1/responses — the OpenAI Responses API. It
// translates the request to a chat-completions request for the upstream
// gateway, then translates the response back into the Responses format.
func (s *Server) responses(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
chatReq, err := openai.ParseResponsesRequest(body)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
return
}
meta := openai.ParseResponsesMeta(body)
if chatReq.Model == "" {
chatReq.Model = "zhanlu/auto"
}
start := time.Now()
clientWantsStream := chatReq.Stream
chatReq.Stream = true // upstream always streams
upstreamBody, err := chatReq.MarshalForUpstream()
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
resp, ok := s.callUpstream(w, r, creds, chatReq.Model, upstreamBody, clientWantsStream, start)
if !ok {
return
}
defer resp.Body.Close()
if clientWantsStream {
usage, status := s.proxyResponsesStream(w, resp, chatReq.Model, meta)
s.record(chatReq.Model, true, usage, status, start)
return
}
usage, status := s.aggregateResponsesStream(w, resp, chatReq.Model, meta)
s.record(chatReq.Model, false, usage, status, start)
}
// ---------------------------------------------------------------- non-streaming
// aggregateResponsesStream reads the upstream SSE, aggregates it (same logic
// as aggregateStream), then emits a Responses API JSON object.
func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event chatStreamChunk
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
if event.ID != "" {
id = event.ID
}
if event.Usage != nil {
usage = event.Usage
}
if len(event.Choices) > 0 {
content += event.Choices[0].Delta.Content
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
for _, part := range event.Choices[0].Delta.ToolCalls {
call := toolCalls[part.Index]
if call == nil {
call = &toolCall{Type: "function"}
toolCalls[part.Index] = call
}
if part.ID != "" {
call.ID = part.ID
}
if part.Type != "" {
call.Type = part.Type
}
call.Function.Name += part.Function.Name
call.Function.Arguments += part.Function.Arguments
}
if event.Choices[0].FinishReason != nil {
finishReason = *event.Choices[0].FinishReason
}
}
return nil
})
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
return nil, "upstream_error"
}
_ = id
// Map the upstream finish_reason onto the Responses API status: a
// length/content_filter cutoff means the response is incomplete, not
// completed. Default ("stop"/"tool_calls") stays "completed".
status := "completed"
switch finishReason {
case "length", "content_filter":
status = "incomplete"
}
respID := "resp_" + randomRequestID()
output := []any{}
if reasoning != "" {
output = append(output, map[string]any{
"type": "reasoning", "id": "rs_" + randomRequestID(), "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": reasoning}},
})
}
// Message output item — included when there is text content.
if content != "" {
output = append(output, map[string]any{
"type": "message", "id": "msg_" + randomRequestID(), "status": "completed",
"role": "assistant",
"content": []map[string]any{{
"type": "output_text", "text": content,
"annotations": []any{}, "logprobs": []any{},
}},
})
}
if len(toolCalls) > 0 {
indices := make([]int, 0, len(toolCalls))
for i := range toolCalls {
indices = append(indices, i)
}
sort.Ints(indices)
ordered := make([]*toolCall, 0, len(toolCalls))
for _, i := range indices {
ordered = append(ordered, toolCalls[i])
}
for _, tc := range ordered {
output = append(output, map[string]any{
"type": "function_call", "id": "fc_" + randomRequestID(),
"call_id": tc.ID, "name": tc.Function.Name,
"arguments": tc.Function.Arguments, "status": "completed",
})
}
}
// If there was no content and no tool calls, still emit an empty message
// so the response always has at least one output item.
if len(output) == 0 {
output = append(output, map[string]any{
"type": "message", "id": "msg_" + randomRequestID(), "status": "completed",
"role": "assistant", "content": []map[string]any{},
})
}
result := map[string]any{
"id": respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": status, "output": output,
"parallel_tool_calls": meta.ParallelToolCalls,
"tool_choice": meta.ToolChoice,
"tools": meta.Tools,
}
if usage != nil {
result["usage"] = openai.UsageToResponses(usage)
}
writeJSON(w, http.StatusOK, result)
return usage, "success"
}
// ------------------------------------------------------------------- streaming
// responsesStreamState tracks the lifecycle of output items while converting
// Chat Completions SSE into Responses API SSE events.
type responsesStreamState struct {
w http.ResponseWriter
flusher http.Flusher
respID string
msgID string
rsID string
meta openai.ResponsesMeta
outputIdx int
reasoningOn bool
reasonPartOn bool
messageOn bool
partOn bool
fullContent string
fullReasoning string
usage any
status string
finishReason string
// tool call tracking
tools map[int]*streamToolCall
toolOrder []int
}
type streamToolCall struct {
itemID string
callID string
name string
args string
started bool
doneIdx int // assigned output index when started
}
func newResponsesStreamState(w http.ResponseWriter, model string, meta openai.ResponsesMeta) *responsesStreamState {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
st := &responsesStreamState{
w: w,
flusher: flusher,
respID: "resp_" + randomRequestID(),
msgID: "msg_" + randomRequestID(),
rsID: "rs_" + randomRequestID(),
meta: meta,
tools: map[int]*streamToolCall{},
status: "success",
}
baseResp := func(st2 string) map[string]any {
return map[string]any{
"id": st.respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": st2, "output": []any{},
"parallel_tool_calls": meta.ParallelToolCalls,
"tool_choice": meta.ToolChoice,
"tools": meta.Tools,
}
}
st.emit("response.created", map[string]any{"type": "response.created", "response": baseResp("in_progress")})
st.emit("response.in_progress", map[string]any{"type": "response.in_progress", "response": baseResp("in_progress")})
return st
}
func (st *responsesStreamState) emit(event string, data any) {
b, _ := json.Marshal(data)
fmt.Fprintf(st.w, "event: %s\ndata: %s\n\n", event, b)
if st.flusher != nil {
st.flusher.Flush()
}
}
// closeReasoning closes the reasoning output item if it is open.
func (st *responsesStreamState) closeReasoning() {
if !st.reasoningOn {
return
}
if st.reasonPartOn {
st.emit("response.reasoning_summary_text.done", map[string]any{
"type": "response.reasoning_summary_text.done", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0, "text": st.fullReasoning,
})
st.emit("response.reasoning_summary_part.done", map[string]any{
"type": "response.reasoning_summary_part.done", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0,
"part": map[string]any{"type": "summary_text", "text": st.fullReasoning},
})
st.reasonPartOn = false
}
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": st.outputIdx,
"item": map[string]any{
"type": "reasoning", "id": st.rsID, "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}},
},
})
st.reasoningOn = false
st.outputIdx++
}
// closeMessage closes the message output item if it is open.
func (st *responsesStreamState) closeMessage() {
if !st.messageOn {
return
}
if st.partOn {
st.emit("response.output_text.done", map[string]any{
"type": "response.output_text.done", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0, "text": st.fullContent,
})
st.emit("response.content_part.done", map[string]any{
"type": "response.content_part.done", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}},
})
st.partOn = false
}
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": st.outputIdx,
"item": map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}},
},
})
st.messageOn = false
st.outputIdx++
}
// handleReasoning processes a reasoning content delta.
func (st *responsesStreamState) handleReasoning(delta string) {
if !st.reasoningOn {
st.reasoningOn = true
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "reasoning", "id": st.rsID, "status": "in_progress",
"content": []any{}, "summary": []any{},
},
})
st.emit("response.reasoning_summary_part.added", map[string]any{
"type": "response.reasoning_summary_part.added", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0,
"part": map[string]any{"type": "summary_text", "text": ""},
})
st.reasonPartOn = true
}
st.fullReasoning += delta
st.emit("response.reasoning_summary_text.delta", map[string]any{
"type": "response.reasoning_summary_text.delta", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0, "delta": delta,
})
}
// handleContent processes a text content delta.
func (st *responsesStreamState) handleContent(delta string) {
// Close reasoning if open — text content comes after reasoning.
st.closeReasoning()
if !st.messageOn {
st.messageOn = true
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "message", "id": st.msgID, "status": "in_progress", "role": "assistant", "content": []any{},
},
})
st.emit("response.content_part.added", map[string]any{
"type": "response.content_part.added", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}},
})
st.partOn = true
}
st.fullContent += delta
st.emit("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0, "delta": delta,
})
}
// handleToolCall processes a tool call delta from the chat completion stream.
func (st *responsesStreamState) handleToolCall(index int, id, name, args string) {
// Close reasoning/message if open — tool calls are separate output items.
st.closeReasoning()
st.closeMessage()
call := st.tools[index]
if call == nil {
call = &streamToolCall{itemID: "fc_" + randomRequestID()}
st.tools[index] = call
st.toolOrder = append(st.toolOrder, index)
}
if id != "" {
call.callID = id
}
if name != "" {
call.name += name
}
if !call.started {
call.started = true
call.doneIdx = st.outputIdx
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": "", "status": "in_progress",
},
})
st.outputIdx++
}
if args != "" {
call.args += args
st.emit("response.function_call_arguments.delta", map[string]any{
"type": "response.function_call_arguments.delta", "item_id": call.itemID,
"output_index": call.doneIdx, "delta": args,
})
}
}
// finish closes all open items and sends the response.completed event.
func (st *responsesStreamState) finish(model string) {
st.closeReasoning()
st.closeMessage()
for _, idx := range st.toolOrder {
call := st.tools[idx]
st.emit("response.function_call_arguments.done", map[string]any{
"type": "response.function_call_arguments.done", "item_id": call.itemID,
"output_index": call.doneIdx, "arguments": call.args,
})
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": call.doneIdx,
"item": map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": call.args, "status": "completed",
},
})
}
// Build the final output array for the completed event.
finalOutput := []any{}
if st.fullReasoning != "" {
finalOutput = append(finalOutput, map[string]any{
"type": "reasoning", "id": st.rsID, "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}},
})
}
if st.fullContent != "" {
finalOutput = append(finalOutput, map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}},
})
}
for _, idx := range st.toolOrder {
call := st.tools[idx]
finalOutput = append(finalOutput, map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": call.args, "status": "completed",
})
}
// Ensure at least one output item exists.
if len(finalOutput) == 0 {
finalOutput = append(finalOutput, map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{},
})
}
// Map the upstream finish_reason onto the Responses API status, mirroring
// the non-streaming path: a length/content_filter cutoff is "incomplete".
respStatus := "completed"
switch st.finishReason {
case "length", "content_filter":
respStatus = "incomplete"
}
completedResp := map[string]any{
"id": st.respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": respStatus, "output": finalOutput,
"parallel_tool_calls": st.meta.ParallelToolCalls,
"tool_choice": st.meta.ToolChoice,
"tools": st.meta.Tools,
}
if st.usage != nil {
completedResp["usage"] = openai.UsageToResponses(st.usage)
}
st.emit("response.completed", map[string]any{"type": "response.completed", "response": completedResp})
}
// proxyResponsesStream reads the upstream Chat Completions SSE and emits
// Responses API SSE events via a responsesStreamState state machine.
func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) {
st := newResponsesStreamState(w, model, meta)
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if line != "" {
payload := sseDataPayload(line)
if payload != "" && payload != "[DONE]" {
var evt struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Usage any `json:"usage"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if jErr := json.Unmarshal([]byte(payload), &evt); jErr == nil {
if evt.State == "ERROR" {
st.status = "upstream_error"
}
if evt.Usage != nil {
st.usage = evt.Usage
}
if len(evt.Choices) > 0 {
delta := evt.Choices[0].Delta
if evt.Choices[0].FinishReason != nil {
st.finishReason = *evt.Choices[0].FinishReason
}
reasoningDelta := delta.ReasoningContent + delta.Reasoning
if reasoningDelta != "" {
st.handleReasoning(reasoningDelta)
}
if delta.Content != "" {
st.handleContent(delta.Content)
}
for _, tc := range delta.ToolCalls {
st.handleToolCall(tc.Index, tc.ID, tc.Function.Name, tc.Function.Arguments)
}
}
}
}
}
if err != nil {
if err != io.EOF {
b, _ := json.Marshal(map[string]any{
"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error",
})
fmt.Fprintf(w, "data: %s\n\n", b)
if st.flusher != nil {
st.flusher.Flush()
}
st.status = "upstream_error"
}
break
}
}
st.finish(model)
return st.usage, st.status
}
+304
View File
@@ -0,0 +1,304 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
// TestResponsesNonStreaming verifies POST /v1/responses with stream:false
// returns a properly formatted Responses API JSON object.
func TestResponsesNonStreaming(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)
}
body := `{"model":"GLM-4.7","input":"hi","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if result["object"] != "response" {
t.Fatalf("object = %v", result["object"])
}
if result["status"] != "completed" {
t.Fatalf("status = %v", result["status"])
}
output, ok := result["output"].([]any)
if !ok || len(output) == 0 {
t.Fatalf("output = %v", result["output"])
}
msg, ok := output[0].(map[string]any)
if !ok {
t.Fatalf("output[0] type = %T", output[0])
}
if msg["type"] != "message" {
t.Fatalf("output[0] type = %v", msg["type"])
}
if msg["role"] != "assistant" {
t.Fatalf("output[0] role = %v", msg["role"])
}
content, ok := msg["content"].([]any)
if !ok || len(content) == 0 {
t.Fatalf("content = %v", msg["content"])
}
part, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] type = %T", content[0])
}
if part["type"] != "output_text" {
t.Fatalf("content[0] type = %v", part["type"])
}
if part["text"] != "hello" {
t.Fatalf("content[0] text = %v", part["text"])
}
// Verify required SDK fields are present.
if _, ok := result["parallel_tool_calls"]; !ok {
t.Fatal("missing parallel_tool_calls in response")
}
if _, ok := result["tool_choice"]; !ok {
t.Fatal("missing tool_choice in response")
}
if _, ok := result["tools"]; !ok {
t.Fatal("missing tools in response")
}
// Verify output_text has logprobs field.
if _, ok := part["logprobs"]; !ok {
t.Fatal("missing logprobs in output_text content part")
}
usage, ok := result["usage"].(map[string]any)
if !ok {
t.Fatalf("usage = %v", result["usage"])
}
if totNum(usage["input_tokens"]) != 10 {
t.Fatalf("input_tokens = %v", usage["input_tokens"])
}
if totNum(usage["output_tokens"]) != 20 {
t.Fatalf("output_tokens = %v", usage["output_tokens"])
}
if totNum(usage["total_tokens"]) != 30 {
t.Fatalf("total_tokens = %v", usage["total_tokens"])
}
}
// TestResponsesStreaming verifies POST /v1/responses with stream:true
// emits proper Responses API SSE events.
func TestResponsesStreaming(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)
}
body := `{"model":"GLM-4.7","input":"hi","stream":true}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
// Check key lifecycle events are present.
checks := []string{
"event: response.created",
"event: response.in_progress",
"event: response.output_item.added",
"event: response.content_part.added",
"event: response.output_text.delta",
"event: response.output_text.done",
"event: response.content_part.done",
"event: response.output_item.done",
"event: response.completed",
}
for _, c := range checks {
if !strings.Contains(s, c) {
t.Fatalf("missing %q in SSE body:\n%s", c, s)
}
}
// Verify the text delta contains "hello".
if !strings.Contains(s, `"delta":"hello"`) {
t.Fatalf("text delta missing 'hello' in SSE body:\n%s", s)
}
// Verify required SDK fields are present in completed event.
if !strings.Contains(s, `"parallel_tool_calls"`) {
t.Fatalf("missing parallel_tool_calls in SSE body:\n%s", s)
}
if !strings.Contains(s, `"tool_choice"`) {
t.Fatalf("missing tool_choice in SSE body:\n%s", s)
}
// Verify logprobs field in output_text.
if !strings.Contains(s, `"logprobs"`) {
t.Fatalf("missing logprobs in SSE body:\n%s", s)
}
// Verify usage in the completed event has input_tokens.
if !strings.Contains(s, `"input_tokens":`) {
t.Fatalf("usage missing input_tokens in SSE body:\n%s", s)
}
}
// TestResponsesStringInput verifies the proxy correctly translates a string
// "input" to a chat-completions "messages" array before forwarding upstream.
func TestResponsesStringInput(t *testing.T) {
var capturedBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/chat/completions" {
capturedBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
return
}
http.NotFound(w, r)
}))
defer upstream.Close()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := config.Config{
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
defer proxy.Close()
creds := auth.Credentials{
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
_ = st.SaveCredentials(creds)
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if len(capturedBody) == 0 {
t.Fatal("no upstream request body captured")
}
var upstreamReq map[string]any
if err := json.Unmarshal(capturedBody, &upstreamReq); err != nil {
t.Fatal(err)
}
messages, ok := upstreamReq["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %v", upstreamReq["messages"])
}
msg, _ := messages[0].(map[string]any)
if msg["role"] != "user" || msg["content"] != "hello world" {
t.Fatalf("upstream message = %v", msg)
}
}
// TestResponsesInstructions verifies instructions are prepended as a system
// message in the upstream request.
func TestResponsesInstructions(t *testing.T) {
var capturedBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/chat/completions" {
capturedBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
return
}
http.NotFound(w, r)
}))
defer upstream.Close()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := config.Config{
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
defer proxy.Close()
creds := auth.Credentials{
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
_ = st.SaveCredentials(creds)
body := `{"model":"GLM-4.7","instructions":"be concise","input":"hello","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if len(capturedBody) == 0 {
t.Fatal("no upstream request body captured")
}
var upstreamReq map[string]any
_ = json.Unmarshal(capturedBody, &upstreamReq)
messages, _ := upstreamReq["messages"].([]any)
if len(messages) != 2 {
t.Fatalf("messages = %d items", len(messages))
}
sys, _ := messages[0].(map[string]any)
if sys["role"] != "system" || sys["content"] != "be concise" {
t.Fatalf("system message = %v", sys)
}
}
+497 -491
View File
File diff suppressed because it is too large Load Diff
+238 -13
View File
@@ -15,12 +15,16 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to it.
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string) {
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to
// it. The proxy is backed by a temp SQLite store so credentials persist in the
// db during the test instead of a JSON file.
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, *store.Store) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -47,7 +51,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":1}}\n\n")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20,\"total_tokens\":30,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
case "/gateway/v1/model/info":
@@ -59,28 +63,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{
ListenAddr: ":0",
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
CredentialsPath: credsFile,
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
TokenDecryptKey: "3jw7woww2rvhla6k",
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg)
h := New(cfg, st)
proxy := httptest.NewServer(h)
return upstream, proxy, credsFile
return upstream, proxy, st
}
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
func TestPhoneLoginAndChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
@@ -107,8 +115,8 @@ func TestPhoneLoginAndChat(t *testing.T) {
t.Fatalf("login failed: %v", loginResp)
}
// 3. credentials file should contain the provisioned api key
creds, err := auth.LoadCredentials(credsFile)
// 3. credentials store should contain the provisioned api key
creds, err := st.LoadCredentials()
if err != nil {
t.Fatal(err)
}
@@ -154,11 +162,11 @@ func TestPhoneLoginAndChat(t *testing.T) {
}
func TestStreamingChat(t *testing.T) {
upstream, proxy, credsFile := setupTestServer(t)
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// Seed credentials directly with the api key
// Seed credentials directly with the api key into the store
creds := auth.Credentials{
AccessKey: "AK",
SecretKey: "SK",
@@ -167,7 +175,7 @@ func TestStreamingChat(t *testing.T) {
ModelBaseURL: upstream.URL,
Email: "[email protected]",
}
if err := auth.SaveCredentials(credsFile, creds); err != nil {
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
@@ -188,6 +196,223 @@ 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)
}
}
// TestAdminModels verifies the admin /api/models endpoint returns the live
// model list advertised by the upstream gateway model-info endpoint.
func TestAdminModels(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)
}
resp, err := http.Get(proxy.URL + "/api/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("models endpoint not ok: %v", data)
}
models, _ := data["models"].([]any)
if len(models) != 2 {
t.Fatalf("models = %v, want 2", models)
}
}
// TestModelTest verifies the admin /api/models/test endpoint probes a model and
// reports availability plus latency against the mock streaming upstream.
func TestModelTest(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)
}
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("test endpoint not ok: %v", data)
}
if available, _ := data["available"].(bool); !available {
t.Fatalf("model should be available: %v", data)
}
if _, ok := data["ttft_ms"]; !ok {
t.Fatalf("ttft_ms should be present: %v", data)
}
}
// TestModelTestNoCredentials verifies the test endpoint reports unavailable
// gracefully when no credentials are configured, instead of erroring.
func TestModelTestNoCredentials(t *testing.T) {
upstream, proxy, _ := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("test endpoint should stay ok: %v", data)
}
if available, _ := data["available"].(bool); available {
t.Fatalf("model should not be available without creds: %v", data)
}
}
// TestAdminRendersModelRangeFilter verifies the admin page renders without
// panic for both empty and populated summaries, and that the by-model panel
// carries the 1d/7d/all range filter controls and a client-renderable tbody.
func TestAdminRendersModelRangeFilter(t *testing.T) {
populated := &stats.Summary{
PerModel: []stats.ModelStat{
{Model: "GLM-4.7", Requests: 3, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4},
},
}
cases := []struct {
name string
summary *stats.Summary
}{
{"empty", &stats.Summary{}},
{"populated", populated},
}
for _, tc := range cases {
var buf bytes.Buffer
if err := adminTemplate.Execute(&buf, map[string]any{
"Enabled": true,
"Stats": tc.summary,
"DBPath": "zhanlu.db",
"PasswordEnabled": false,
}); err != nil {
t.Fatalf("%s: render admin: %v", tc.name, err)
}
body := buf.String()
for _, want := range []string{
`id="model-range"`,
`data-range="1d"`,
`data-range="7d"`,
`data-range="all"`,
`id="model-tbody"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("%s: admin output missing %q", tc.name, want)
}
}
}
}
// totNum extracts an int from a JSON-decoded numeric value (float64).
func totNum(v any) int {
f, _ := v.(float64)
return int(f)
}
func okValue(m map[string]any) bool {
ok, _ := m["ok"].(bool)
return ok
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"embed"
"fmt"
"html/template"
)
// templateFS holds the rendered admin/login HTML pages. Keeping them as
// separate files (rather than inline raw-string literals in server.go) gives
// them real syntax highlighting and keeps server.go focused on handlers.
//
//go:embed templates/*.html
var templateFS embed.FS
var (
loginTemplate = mustParseTemplate("login.html", "login")
loginResultTemplate = mustParseTemplate("login_result.html", "login-result")
adminTemplate = mustParseTemplateFuncs("admin.html", "admin", 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,
})
)
// mustParseTemplate reads a single embedded template file and parses it,
// panicking on error (a malformed template is a build-time mistake).
func mustParseTemplate(filename, name string) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Parse(string(data)))
}
// mustParseTemplateFuncs is mustParseTemplate with a FuncMap registered before
// parsing, so the template body may reference the custom functions.
func mustParseTemplateFuncs(filename, name string, funcs template.FuncMap) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Funcs(funcs).Parse(string(data)))
}
+526
View File
@@ -0,0 +1,526 @@
<!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-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.panel-head h2{margin:0}
.seg{display:inline-flex;border:1px solid var(--border-strong);border-radius:999px;padding:2px;background:var(--bg)}
.seg-btn{border:0;background:none;font:inherit;font-weight:600;font-size:12.5px;color:var(--body);padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease}
.seg-btn:hover{color:var(--ink)}
.seg-btn[aria-current="true"]{background:var(--accent);color:#fff}
.seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.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="models" aria-selected="false">可用模型</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">
<div class="panel-head">
<h2>按模型</h2>
<div class="seg" id="model-range" role="group" aria-label="按模型统计时间范围">
<button class="seg-btn" type="button" data-range="1d">1 天</button>
<button class="seg-btn" type="button" data-range="7d">7 天</button>
<button class="seg-btn" type="button" data-range="all" aria-current="true">全部</button>
</div>
</div>
<div class="scroll" id="model-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 id="model-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>
<p class="empty" id="model-empty" style="display:none">暂无数据</p>
</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="models" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
</div>
<div class="panel">
<h2>可用模型</h2>
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
<div class="scroll">
<table id="models-table">
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</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">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
</div>
</section>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tabpanel');
function activate(name) {
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
}
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 === 'models') activate('models');
else 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(); });
});
// by-model time-range filter (1d / 7d / all) — fetches /api/stats with a
// since cutoff and re-renders only the per-model table client-side.
(function () {
var seg = document.getElementById('model-range');
var tbody = document.getElementById('model-tbody');
var scroll = document.getElementById('model-scroll');
var empty = document.getElementById('model-empty');
if (!seg || !tbody) return;
function trimZero(s) { return s.indexOf('.') >= 0 ? s.replace(/\.0$/, '') : s; }
function human(v) {
var f = Number(v) || 0;
if (f < 1000) return String(Math.round(f));
if (f < 1e6) return trimZero((f / 1e3).toFixed(1)) + 'K';
if (f < 1e9) return trimZero((f / 1e6).toFixed(1)) + 'M';
return trimZero((f / 1e9).toFixed(1)) + 'B';
}
function rate(cached, prompt) {
if (prompt <= 0) return '0%';
return (cached / prompt * 100).toFixed(1) + '%';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function showEmpty(yes) {
scroll.style.display = yes ? 'none' : '';
empty.style.display = yes ? '' : 'none';
}
function render(rows) {
if (!rows || rows.length === 0) { tbody.innerHTML = ''; showEmpty(true); return; }
showEmpty(false);
var html = '';
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
html += '<tr><td>' + escapeHtml(r.model) + '</td>' +
'<td class="num">' + (r.requests || 0) + '</td>' +
'<td class="num">' + human(r.prompt_tokens) + '</td>' +
'<td class="num">' + human(r.completion_tokens) + '</td>' +
'<td class="num">' + human(r.total_tokens) + '</td>' +
'<td class="num">' + human(r.cached_tokens) + '</td>' +
'<td class="num">' + rate(r.cached_tokens, r.prompt_tokens) + '</td></tr>';
}
tbody.innerHTML = html;
}
// initial empty check (server-rendered "all" may have no rows)
if (!tbody.querySelector('tr')) showEmpty(true);
function sinceParam(range) {
if (range === 'all') return '';
var days = range === '1d' ? 1 : 7;
return 'since=' + encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString());
}
seg.addEventListener('click', async function (ev) {
var btn = ev.target.closest('.seg-btn');
if (!btn) return;
seg.querySelectorAll('.seg-btn').forEach(function (b) {
b.setAttribute('aria-current', b === btn ? 'true' : 'false');
});
var range = btn.dataset.range;
var param = sinceParam(range);
var url = '/api/stats' + (param ? '?' + param : '');
showEmpty(false);
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载中…</td></tr>';
try {
var res = await fetch(url);
var data = await res.json();
if (!data.stats) { render([]); return; }
render(data.stats.per_model || []);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载失败</td></tr>';
}
});
})();
// 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() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (form) {
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
if (codeButton) codeButton.addEventListener('click', async function () {
var telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
var data = await res.json();
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
if (form) form.addEventListener('submit', async function (event) {
event.preventDefault();
var telephone = form.telephone.value.trim();
var code = form.code.value.trim();
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
setStatus('正在登录并保存凭据...', 'busy');
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();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
})();
(function () {
var refreshBtn = document.getElementById('models-refresh');
var testAllBtn = document.getElementById('models-test-all');
var statusEl = document.getElementById('models-status');
var tbody = document.querySelector('#models-table tbody');
if (!refreshBtn || !tbody) return;
var loaded = false;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function renderRow(model) {
var tr = document.createElement('tr');
tr.dataset.model = model;
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
'<td class="m-status"><span class="muted">未测试</span></td>' +
'<td class="num m-ttft">—</td>' +
'<td class="num m-total">—</td>' +
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
return tr;
}
async function loadModels() {
setStatus('正在获取模型列表...', 'busy');
refreshBtn.disabled = true;
try {
var res = await fetch('/api/models');
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
var models = data.models || [];
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
tbody.innerHTML = '';
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
} catch (e) {
setStatus('获取模型列表失败:' + e.message, 'err');
} finally {
refreshBtn.disabled = false;
}
}
async function testModel(model, row) {
var statusCell = row.querySelector('.m-status');
var ttftCell = row.querySelector('.m-ttft');
var totalCell = row.querySelector('.m-total');
var btn = row.querySelector('.m-test');
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
ttftCell.textContent = '—';
totalCell.textContent = '—';
btn.disabled = true;
try {
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
var data = await res.json();
if (data.available) {
statusCell.innerHTML = '<span class="badge ok">可用</span>';
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
} else {
statusCell.innerHTML = '<span class="badge err">不可用</span>';
statusCell.title = data.error || '';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
}
} catch (e) {
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
statusCell.title = e.message;
} finally {
btn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadModels);
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
await testModel(rows[i].dataset.model, rows[i]);
}
});
tbody.addEventListener('click', function (ev) {
var btn = ev.target.closest('.m-test');
if (!btn) return;
var row = btn.closest('tr');
if (row && row.dataset.model) testModel(row.dataset.model, row);
});
// lazy-load the model list the first time the tab becomes active
function loadIfActive() {
if (loaded) return;
var panel = document.querySelector('.tabpanel[data-tab="models"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
}
var modelsTab = document.querySelector('.tab[data-tab="models"]');
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
</script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!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;
--radius:16px; --radius-sm:10px;
}
*{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%);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}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.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: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>
</head>
<body>
<main class="card">
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
<h1>湛卢代理登录</h1>
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入管理后台</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
</main>
<script>
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>
@@ -0,0 +1,41 @@
<!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>
+83
View File
@@ -0,0 +1,83 @@
// Package stats defines the types and helpers for OpenAI-compatible token
// usage statistics recorded by the zhanlu proxy. The concrete SQLite-backed
// recorder lives in internal/store; this package is dependency-free so it can
// be referenced by both store and server without import cycles.
package stats
import "time"
// Record is a single chat-completion usage observation.
type Record struct {
Ts time.Time `json:"ts"`
Model string `json:"model"`
Stream bool `json:"stream"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
Status string `json:"status"` // "success" | "upstream_error"
LatencyMs int64 `json:"latency_ms"`
}
// Query filters the recorded stats. Zero-value time fields mean unbounded on
// that end; empty Model means all models. Limit caps the recent-records list
// (0 = default). Stream filters by streaming mode (nil = both).
type Query struct {
Since time.Time
Until time.Time
Model string
Limit int
Stream *bool
}
// Totals aggregates request counts and token sums over a filtered set.
type Totals struct {
Requests int `json:"requests"`
SuccessRequests int `json:"success_requests"`
ErrorRequests int `json:"error_requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
CachedTokens int64 `json:"cached_tokens"`
CacheRate float64 `json:"cache_rate"` // cached_tokens / prompt_tokens, 0..1
}
// ModelStat is a per-model aggregation row.
type ModelStat struct {
Model string `json:"model"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// DayStat is a per-day aggregation row (server-local time, YYYY-MM-DD).
type DayStat struct {
Day string `json:"day"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// Summary is the full result returned by a Recorder's Stats query.
type Summary struct {
Totals Totals `json:"totals"`
PerModel []ModelStat `json:"per_model"`
Daily []DayStat `json:"daily"`
Recent []Record `json:"recent"`
}
// Recorder persists and queries usage statistics. The concrete implementation
// lives in internal/store; the interface is declared here so server code can
// depend on the contract and tests can inject fakes.
type Recorder interface {
Record(r Record) error
Stats(q Query) (*Summary, error)
Reset() error
Close() error
}
+78
View File
@@ -0,0 +1,78 @@
package stats
import (
"encoding/json"
"time"
)
// Usage is the subset of the OpenAI chat-completion usage object the recorder
// persists. Numbers arrive from JSON unmarshal as float64.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
// PromptTokensDetails.CachedTokens is emitted by providers that support
// prompt caching (OpenAI/DeepSeek/Zhipu litellm gateways). Some upstreams
// put cached_tokens at the top level instead.
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
// CompletionTokensDetails.ReasoningTokens is emitted by reasoning models;
// some upstreams put reasoning_tokens at top level instead.
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
}
// ExtractUsage decodes a raw usage value (as produced by encoding/json into an
// any) into token counts. It accepts both full usage maps and raw JSON bytes.
// Missing fields default to 0; a nil v yields zero usage.
func ExtractUsage(v any) Usage {
var u Usage
if v == nil {
return u
}
switch t := v.(type) {
case []byte:
_ = json.Unmarshal(t, &u)
case json.RawMessage:
_ = json.Unmarshal(t, &u)
case map[string]any:
// Re-marshal + unmarshal is the simplest robust path for nested
// *_tokens_details; usage payloads are tiny.
if b, err := json.Marshal(t); err == nil {
_ = json.Unmarshal(b, &u)
}
}
if u.ReasoningTokens == 0 {
u.ReasoningTokens = u.CompletionTokensDetails.ReasoningTokens
}
if u.CachedTokens == 0 {
u.CachedTokens = u.PromptTokensDetails.CachedTokens
}
return u
}
// RecordFromUsage builds a Record from a captured usage value plus context.
func RecordFromUsage(model string, stream bool, usage any, status string, start time.Time) Record {
u := ExtractUsage(usage)
if u.TotalTokens == 0 && (u.PromptTokens != 0 || u.CompletionTokens != 0) {
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return Record{
Ts: time.Now(),
Model: model,
Stream: stream,
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
ReasoningTokens: u.ReasoningTokens,
CachedTokens: u.CachedTokens,
Status: status,
LatencyMs: time.Since(start).Milliseconds(),
}
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
// LoadCredentials reads the persisted credentials. The credentials row always
// exists after Open; an empty row (nothing saved yet) yields a zero-value
// Credentials with a nil error — callers check Validate()/HasAPIKey().
func (s *Store) LoadCredentials() (auth.Credentials, error) {
var c auth.Credentials
var savedAt int64
err := s.db.QueryRow(`SELECT access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at FROM credentials WHERE id = 1`).
Scan(&c.AccessKey, &c.SecretKey, &c.Token, &c.APIKey, &c.ModelBaseURL, &c.Email, &c.Organization, &c.Team, &c.BaseURL, &savedAt)
if err != nil {
return auth.Credentials{}, fmt.Errorf("load credentials: %w", err)
}
c.SavedAt = time.Unix(savedAt, 0).Local()
return c, nil
}
// SaveCredentials upserts the credentials into the single row. It only writes
// when the credentials validate or already carry an API key, so partial /
// env-only creds are not persisted.
func (s *Store) SaveCredentials(c auth.Credentials) error {
if c.Validate() != nil && !c.HasAPIKey() {
return fmt.Errorf("save credentials: %w", c.Validate())
}
c.SavedAt = time.Now()
_, err := s.db.Exec(`INSERT INTO credentials (id, access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
access_key=excluded.access_key, secret_key=excluded.secret_key, token=excluded.token,
api_key=excluded.api_key, model_base_url=excluded.model_base_url, email=excluded.email,
organization=excluded.organization, team=excluded.team, base_url=excluded.base_url,
saved_at=excluded.saved_at`,
c.AccessKey, c.SecretKey, c.Token, c.APIKey, c.ModelBaseURL, c.Email, c.Organization, c.Team, c.BaseURL, c.SavedAt.Unix())
if err != nil {
return fmt.Errorf("save credentials: %w", err)
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
// Compile-time guard: *Store satisfies stats.Recorder.
var _ stats.Recorder = (*Store)(nil)
const defaultRecentLimit = 200
// Record appends a single usage observation.
func (s *Store) Record(rec stats.Record) error {
if rec.Status == "" {
rec.Status = "success"
}
stream := 0
if rec.Stream {
stream = 1
}
_, err := s.db.Exec(`INSERT INTO requests (ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms) VALUES (?,?,?,?,?,?,?,?,?,?)`,
rec.Ts.Unix(), rec.Model, stream, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens, rec.ReasoningTokens, rec.CachedTokens, rec.Status, rec.LatencyMs)
return err
}
// Stats computes the aggregate summary for the given query.
func (s *Store) Stats(q stats.Query) (*stats.Summary, error) {
q = normalizeQuery(q)
where, args := whereClause(q)
sum := &stats.Summary{}
if err := s.scanTotals(sum, where, args); err != nil {
return nil, err
}
if sum.Totals.PromptTokens > 0 {
sum.Totals.CacheRate = float64(sum.Totals.CachedTokens) / float64(sum.Totals.PromptTokens)
}
if err := s.scanPerModel(sum, where, args); err != nil {
return nil, err
}
if err := s.scanDaily(sum, where, args); err != nil {
return nil, err
}
if err := s.scanRecent(sum, q, where, args); err != nil {
return nil, err
}
return sum, nil
}
// Reset deletes all recorded usage statistics (the credentials row is kept).
func (s *Store) Reset() error {
_, err := s.db.Exec(`DELETE FROM requests`)
return err
}
func normalizeQuery(q stats.Query) stats.Query {
if q.Limit <= 0 {
q.Limit = defaultRecentLimit
}
if q.Limit > 5000 {
q.Limit = 5000
}
return q
}
func whereClause(q stats.Query) (string, []any) {
var conds []string
var args []any
if !q.Since.IsZero() {
conds = append(conds, "ts >= ?")
args = append(args, q.Since.Unix())
}
if !q.Until.IsZero() {
conds = append(conds, "ts <= ?")
args = append(args, q.Until.Unix())
}
if q.Model != "" {
conds = append(conds, "model = ?")
args = append(args, q.Model)
}
if q.Stream != nil {
conds = append(conds, "stream = ?")
args = append(args, boolToInt(*q.Stream))
}
if len(conds) == 0 {
return "", args
}
out := ""
for i, p := range conds {
if i > 0 {
out += " AND "
}
out += p
}
return " WHERE " + out, args
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *Store) scanTotals(sum *stats.Summary, where string, args []any) error {
q := `SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status!='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(prompt_tokens),0),
COALESCE(SUM(completion_tokens),0),
COALESCE(SUM(total_tokens),0),
COALESCE(SUM(reasoning_tokens),0),
COALESCE(SUM(cached_tokens),0) FROM requests` + where
row := s.db.QueryRow(q, args...)
var success, failures int64
err := row.Scan(&sum.Totals.Requests, &success, &failures,
&sum.Totals.PromptTokens, &sum.Totals.CompletionTokens,
&sum.Totals.TotalTokens, &sum.Totals.ReasoningTokens, &sum.Totals.CachedTokens)
if err != nil {
return fmt.Errorf("scan totals: %w", err)
}
sum.Totals.SuccessRequests = int(success)
sum.Totals.ErrorRequests = int(failures)
return nil
}
func (s *Store) scanPerModel(sum *stats.Summary, where string, args []any) error {
q := `SELECT model, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY model ORDER BY SUM(total_tokens) DESC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan per-model: %w", err)
}
defer rows.Close()
for rows.Next() {
var m stats.ModelStat
if err := rows.Scan(&m.Model, &m.Requests, &m.PromptTokens, &m.CompletionTokens, &m.TotalTokens, &m.CachedTokens); err != nil {
return err
}
sum.PerModel = append(sum.PerModel, m)
}
return rows.Err()
}
func (s *Store) scanDaily(sum *stats.Summary, where string, args []any) error {
q := `SELECT date(ts,'unixepoch','localtime') AS day, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY day ORDER BY day ASC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan daily: %w", err)
}
defer rows.Close()
for rows.Next() {
var d stats.DayStat
if err := rows.Scan(&d.Day, &d.Requests, &d.PromptTokens, &d.CompletionTokens, &d.TotalTokens, &d.CachedTokens); err != nil {
return err
}
sum.Daily = append(sum.Daily, d)
}
return rows.Err()
}
func (s *Store) scanRecent(sum *stats.Summary, q stats.Query, where string, args []any) error {
limit := q.Limit
if limit <= 0 {
limit = defaultRecentLimit
}
query := `SELECT ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms FROM requests` + where + " ORDER BY id DESC LIMIT ?"
rows, err := s.db.Query(query, append(args, limit)...)
if err != nil {
return fmt.Errorf("scan recent: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec stats.Record
var ts int64
var stream int
if err := rows.Scan(&ts, &rec.Model, &stream, &rec.PromptTokens, &rec.CompletionTokens, &rec.TotalTokens, &rec.ReasoningTokens, &rec.CachedTokens, &rec.Status, &rec.LatencyMs); err != nil {
return err
}
rec.Ts = time.Unix(ts, 0).Local()
rec.Stream = stream == 1
sum.Recent = append(sum.Recent, rec)
}
return rows.Err()
}
+89
View File
@@ -0,0 +1,89 @@
// Package store owns the single embedded SQLite database backing the zhanlu
// proxy: token-usage records (the requests table) and the persisted login
// credentials (the credentials table, single-tenant single row). It implements
// stats.Recorder for usage tracking and exposes Load/Save for credentials.
package store
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// Store is the single owner of the proxy's SQLite database handle.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the database at path and ensures both tables exist.
// SQLite is opened with WAL journaling and a busy timeout so concurrent reads
// (stats queries) and writes (request records, credential saves) do not
// collide.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("open db %q: %w", path, err)
}
if err := ensureSchema(db); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close releases the database handle.
func (s *Store) Close() error {
return s.db.Close()
}
func ensureSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
model TEXT NOT NULL,
stream INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success',
latency_ms INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
model_base_url TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
organization TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '',
base_url TEXT NOT NULL DEFAULT '',
saved_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts)`,
`CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model)`,
// Ensure the single credentials row exists so UPSERTs and SELECTs always
// have a target.
`INSERT INTO credentials (id) VALUES (1) ON CONFLICT(id) DO NOTHING`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
return fmt.Errorf("schema: %w", err)
}
}
// Add cached_tokens to databases created before this column existed. SQLite
// returns "duplicate column name" when it already exists; that is expected
// and ignored.
if _, err := db.Exec(`ALTER TABLE requests ADD COLUMN cached_tokens INTEGER NOT NULL DEFAULT 0`); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migrate cached_tokens: %w", err)
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"path/filepath"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestCredentialsRoundTrip(t *testing.T) {
st := newTestStore(t)
in := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOK",
APIKey: "sk-1", ModelBaseURL: "https://up.example", Email: "[email protected]",
Organization: "org", Team: "team",
}
if err := st.SaveCredentials(in); err != nil {
t.Fatalf("save: %v", err)
}
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load: %v", err)
}
if got.APIKey != "sk-1" || got.Email != "[email protected]" || got.Organization != "org" {
t.Fatalf("round-trip mismatch: %+v", got)
}
if got.SavedAt.IsZero() {
t.Fatalf("saved_at not set")
}
}
func TestEmptyLoadReturnsZero(t *testing.T) {
st := newTestStore(t)
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load on empty store: %v", err)
}
if got.HasAPIKey() {
t.Fatalf("expected no api key on empty store, got %+v", got)
}
}
func TestRecordStatsReset(t *testing.T) {
st := newTestStore(t)
now := time.Now()
recs := []stats.Record{
{Ts: now, Model: "glm-4.7", Stream: false, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4, Status: "success", LatencyMs: 5},
{Ts: now, Model: "glm-4.7", Stream: true, PromptTokens: 5, CompletionTokens: 5, TotalTokens: 10, Status: "success", LatencyMs: 8},
{Ts: now, Model: "minimax", Stream: false, PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, Status: "upstream_error", LatencyMs: 3},
}
for _, r := range recs {
if err := st.Record(r); err != nil {
t.Fatalf("record: %v", err)
}
}
sum, err := st.Stats(stats.Query{})
if err != nil {
t.Fatalf("stats: %v", err)
}
if sum.Totals.Requests != 3 {
t.Fatalf("requests = %d, want 3", sum.Totals.Requests)
}
if sum.Totals.SuccessRequests != 2 || sum.Totals.ErrorRequests != 1 {
t.Fatalf("success/error = %d/%d, want 2/1", sum.Totals.SuccessRequests, sum.Totals.ErrorRequests)
}
if sum.Totals.TotalTokens != 42 {
t.Fatalf("total tokens = %d, want 42", sum.Totals.TotalTokens)
}
if sum.Totals.CachedTokens != 4 {
t.Fatalf("cached tokens = %d, want 4", sum.Totals.CachedTokens)
}
// prompt total = 10+5+1 = 16, cached = 4 => 0.25
if sum.Totals.CacheRate < 0.24 || sum.Totals.CacheRate > 0.26 {
t.Fatalf("cache rate = %v, want ~0.25", sum.Totals.CacheRate)
}
if len(sum.PerModel) != 2 {
t.Fatalf("per-model entries = %d, want 2", len(sum.PerModel))
}
// glm-4.7 should lead on total tokens (40 vs 2)
if sum.PerModel[0].Model != "glm-4.7" || sum.PerModel[0].TotalTokens != 40 || sum.PerModel[0].CachedTokens != 4 {
t.Fatalf("top model = %+v, want glm-4.7/40/4 cached", sum.PerModel[0])
}
if len(sum.Recent) != 3 {
t.Fatalf("recent entries = %d, want 3", len(sum.Recent))
}
// most recent first (id desc) => minimax record
if sum.Recent[0].Model != "minimax" {
t.Fatalf("most recent = %+v, want minimax", sum.Recent[0])
}
// model filter
sumF, _ := st.Stats(stats.Query{Model: "minimax"})
if sumF.Totals.Requests != 1 || sumF.Totals.TotalTokens != 2 {
t.Fatalf("filtered stats = %+v, want 1/2", sumF.Totals)
}
if err := st.Reset(); err != nil {
t.Fatalf("reset: %v", err)
}
sum2, _ := st.Stats(stats.Query{})
if sum2.Totals.Requests != 0 {
t.Fatalf("after reset requests = %d, want 0", sum2.Totals.Requests)
}
// credentials must survive a stats reset
creds, _ := st.LoadCredentials()
_ = creds
}
+17
View File
@@ -0,0 +1,17 @@
// Package util holds small shared helpers used across internal packages to
// avoid divergent same-named copies.
package util
import "strings"
// FirstNonEmpty returns the first trimmed-non-empty value, or "" when none of
// the values are non-empty. Callers that need a specific fallback for the
// all-empty case should apply it explicitly at the call site.
func FirstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+4 -12
View File
@@ -13,6 +13,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Client struct {
@@ -93,8 +94,8 @@ func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := firstNonEmpty(organization, "未配置")
tm := firstNonEmpty(team, "未配置")
org := util.FirstNonEmpty(organization, "未配置")
tm := util.FirstNonEmpty(team, "未配置")
// Field order matters: the SM2 signature covers the exact JSON body bytes,
// matching the plugin's JSON.stringify({email, organization, team}).
body, err := json.Marshal(struct {
@@ -195,7 +196,7 @@ func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
seen := map[string]bool{}
models := make([]string, 0, len(out.Data))
for _, m := range out.Data {
id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
if id == "" || seen[id] {
continue
}
@@ -237,15 +238,6 @@ func findString(m map[string]any, keys ...string) string {
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
func randomAlnum(n int) string {
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# 实例测试流程固化脚本
#
# ./scripts/test-instance.sh start # 编译新二进制 + 以当前目录 zhanlu.db 启动
# ./scripts/test-instance.sh stop # 停止进程 + 清理 *-shm/*-wal
# ./scripts/test-instance.sh status # 查看运行状态
#
# 可用环境变量覆盖默认值:
# ZHANLU_DB_FILE 数据库路径 (默认 <项目根>/zhanlu.db)
# ZHANLU_LISTEN_ADDR 监听地址 (默认 127.0.0.1:8080)
set -euo pipefail
# 项目根目录(脚本位于 scripts/ 下)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKDIR="$ROOT/tmp" # 二进制/pid/log 都放这里(已 gitignore)
BIN="$WORKDIR/zhanlu-proxy-test"
PIDFILE="$WORKDIR/test-instance.pid"
LOGFILE="$WORKDIR/test-instance.log"
DB="${ZHANLU_DB_FILE:-$ROOT/zhanlu.db}"
ADDR="${ZHANLU_LISTEN_ADDR:-127.0.0.1:8080}"
start() {
mkdir -p "$WORKDIR"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "已有实例在运行 (pid $(cat "$PIDFILE")),请先执行: $0 stop" >&2
exit 1
fi
echo "==> 编译二进制..."
(cd "$ROOT" && go build -o "$BIN" ./cmd/zhanlu-proxy)
echo "==> 启动 (db=$DB addr=$ADDR)"
ZHANLU_DB_FILE="$DB" ZHANLU_LISTEN_ADDR="$ADDR" \
nohup "$BIN" > "$LOGFILE" 2>&1 &
echo $! > "$PIDFILE"
sleep 1
if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "启动失败,日志:" >&2
cat "$LOGFILE" >&2
rm -f "$PIDFILE"
exit 1
fi
port="${ADDR##*:}"
echo "==> 已启动 pid=$(cat "$PIDFILE")"
echo " 管理后台: http://127.0.0.1:$port/admin"
echo " 日志: $LOGFILE"
echo " 测试完成后执行: $0 stop"
}
stop() {
pid=""
[[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
# 优雅等待退出(给 SQLite checkpoint WAL 的时间)
for _ in $(seq 1 40); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.25
done
if kill -0 "$pid" 2>/dev/null; then
echo "==> 优雅退出超时,强制结束 pid=$pid"
kill -9 "$pid" 2>/dev/null || true
else
echo "==> 已停止进程 pid=$pid"
fi
else
echo "==> 无运行中的进程"
fi
rm -f "$PIDFILE"
# 清理 wal/shm(与 DB 同目录同名)
removed=0
for ext in db-shm db-wal; do
f="${DB%.*}.$ext"
if [[ -f "$f" ]]; then
rm -f "$f"
echo "==> 已清理 $f"
removed=1
fi
done
[[ $removed -eq 0 ]] && echo "==> 无 shm/wal 需清理"
rm -f "$BIN"
echo "==> 完成"
}
status() {
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "运行中 pid=$(cat "$PIDFILE") addr=$ADDR db=$DB"
else
echo "未运行"
fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) echo "用法: $0 {start|stop|status}" >&2; exit 1 ;;
esac