16 Commits
Author SHA1 Message Date
m1saka ecdf0c44d1 Fix upstream 400: flatten multi-part content arrays and sanitize invalid tool_calls
build / build (push) Successful in 2m31s
The Zhanlu upstream gateway returns HTTP 400 (请求消息格式错误) for two
message-structure issues that AI coding tools like MiMoCode produce:

1. Multi-part content arrays — OpenAI SDKs send content as
   [{type:"text",text:"…"}] arrays; the gateway only accepts string
   content. normalizeMessages now flattens text-only arrays into a
   concatenated string (non-text parts like images are preserved).

2. Invalid tool_calls — when a tool call fails, MiMoCode emits
   {name:"invalid", arguments:{"tool":"task","error":"…"}}
   placeholders. The gateway rejects function names not in the tools list
   and non-object arguments (e.g. "-1"). normalizeMessages now
   sanitizes these in place: the real tool name is extracted from the
   arguments' "tool" field (falling back to an arbitrary declared
   tool), and non-JSON-object arguments are replaced with "{}". No
   messages or tool results are removed, preserving the full
   conversation context including error feedback.

Verified with the exact error.md request: 0/10 400 errors after fix
(vs 10/10 before). Model returns valid streaming responses with task
tool calls.
2026-08-23 22:16:32 +08:00
m1saka 22d78c4576 Add recent API request recorder with admin tab and /api/recent endpoint
build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory
ring buffer. Each entry captures request headers (Authorization masked),
request body, response status, and response body — all up to 1 MB.

- recorder.go: RecentRecorder ring buffer, recordingResponseWriter,
  withRecording middleware, getRecent handler
- server.go: add recorder to Server, wrap /v1/ routes, add /api/recent
- admin.html: new 最近请求 tab with lazy-load and expandable cards
- recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
2026-08-23 14:52:33 +08:00
m1saka f988c47fec Fix silent data loss in function_call arguments/output non-string JSON parsing
build / build (push) Successful in 2m29s
responsesInputToMessages unmarshaled function_call.arguments and
function_call_output.output as bare strings, silently dropping the
value when it arrived as an object or content-parts array. This
caused the model to lose tool-call context in multi-turn
conversations, increasing the likelihood of malformed tool-call JSON.

Add rawJSONToString (re-encodes non-string values as JSON strings)
and outputToString (extracts text from content-parts arrays, re-encodes
other non-string values). Add 3 regression tests covering object
arguments, array output, and object output.
2026-08-23 14:15:01 +08:00
m1saka f51ec09a09 gofmt: remove trailing blank lines in client.go
build / build (push) Successful in 2m31s
2026-08-21 15:25:06 +08:00
m1saka b9a3f8d5cd Mirror Zhanlu client: GLM tool_stream, shared v4 UUID request id, drop chat state:ERROR
build / build (push) Successful in 2m26s
2026-08-21 14:27:37 +08:00
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
m1saka 2e402934ea Polish login pages with glass-morphism design
build / build (push) Successful in 1m4s
Redesign the login and login-result pages within the existing dark glass
palette: layered star-dot/grid/gradient background, gradient hairline panel
borders, gradient headline text, primary/ghost button split, and tri-state
status dots (ok/err/busy). Fix zero vertical spacing inside the login form
(the form element had no gap between fields and the submit button).
2026-08-05 19:56:53 +08:00
m1saka df19ced538 Drop ZHANLU_MODELS and ZHANLU_DEFAULT_MODEL config
build / build (push) Successful in 1m5s
Model list is always fetched live from the gateway model-info endpoint, so the
static fallback list was dead config with stale IDs (GLM-4.7 vs zhanlu/glm-4.7).
Default to zhanlu/auto when the request omits model.
2026-08-05 15:06:38 +08:00
m1saka 7d8a5b6f74 Update proxy to Zhanlu v1.4.2 provider flow
The 1.4.2 extension replaced the old signed/encrypted chat gateway with an
OpenAI-compatible aigateway. Align the proxy with the new flow:

- Use ecloud.10086.cn login/model base URLs, zhanlu_ide plugin headers and
  v1.4.2 plugin version
- Provision the model API key via SM2-signed get-or-create after v1/login
  profile fetch; store api_key/model_base_url/email in credentials
- Chat via Bearer apiKey against {modelBaseUrl}/chat/completions with plain
  OpenAI SSE passthrough; fetch /v1/models from the gateway model-info endpoint
- Force HTTP/1.1 upstream (gateway drops HTTP/2 ALPN negotiation with EOF)
- Drop obsolete AES body encryption, model name mapping and vscode headers
2026-08-05 14:55:28 +08:00
m1saka c3af3caa5b Add admin login session flow
build / build (push) Successful in 58s
2026-07-09 00:22:27 +08:00
39 changed files with 6207 additions and 550 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 }}
+9 -1
View File
@@ -1,4 +1,9 @@
credentials.json
zhanlu.db
zhanlu.db-shm
zhanlu.db-wal
*.db
*.db-shm
*.db-wal
extension/
*.exe
*.log
@@ -7,3 +12,6 @@ extension/
!.env.example
tmp/
temp/
source/
output/
error.md
+111 -40
View File
@@ -1,14 +1,17 @@
# Zhanlu Proxy
一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。
一个本地 Go 代理服务,用于读取湛卢v1.4.2插件凭据,按插件认证规则换取模型 API Key,并暴露 OpenAI 兼容接口。
当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。
- OpenAI 兼容接口:`/v1/models``/v1/chat/completions`
- 湛卢签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`
- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions`)、`/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`)。
## 运行
@@ -22,10 +25,10 @@ go run ./cmd/zhanlu-proxy
http://127.0.0.1:8080
```
打开登录页:
打开首页会自动跳转到管理登录页:
```text
http://127.0.0.1:8080/login
http://127.0.0.1:8080/
```
## systemd 服务示例
@@ -36,22 +39,21 @@ http://127.0.0.1:8080/login
/opt/zhanlu-proxy/zhanlu-proxy
```
凭据文件放在:
数据库放在:
```text
/opt/zhanlu-proxy/credentials.json
/opt/zhanlu-proxy/zhanlu.db
```
创建环境变量文件 `/etc/zhanlu-proxy/zhanlu-proxy.env`
```env
ZHANLU_LISTEN_ADDR=:8080
ZHANLU_CREDENTIALS_FILE=/opt/zhanlu-proxy/credentials.json
ZHANLU_SERVER_BASE_URL=https://api-wuxi-1.cmecloud.cn:8443
ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat
ZHANLU_MODELS=glm47,minimax-m25
ZHANLU_DEFAULT_MODEL=minimax-m25
ZHANLU_DB_FILE=/opt/zhanlu-proxy/zhanlu.db
ZHANLU_MOBILE_LOGIN_BASE_URL=https://ecloud.10086.cn
ZHANLU_MOBILE_MODEL_BASE_URL=https://ecloud.10086.cn/api/query/aigateway
ZHANLU_UPSTREAM_TIMEOUT=300s
ZHANLU_LOGIN_PASSWORD=change-this-login-password
OPENAI_COMPAT_API_KEY=change-this-local-secret
```
@@ -97,32 +99,39 @@ journalctl -u zhanlu-proxy -f
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
如果设置了 `ZHANLU_LOGIN_PASSWORD``/login` 只负责管理密码登录。密码正确后服务会设置 HttpOnly 会话 Cookie,并跳转到 `/admin/login``/admin/login` 才是手机号验证码登录湛卢的页面,之后才能查看凭据状态、获取短信验证码、保存凭据或使用备用 SSO 管理接口。
- 生成 16 位一次性 `secret`
- 使用插件内置 RSA 公钥加密手机号和 `secret`
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
- 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`
- 使用本次 `secret` AES 解密响应中的 `ak``sk``license`,得到 `AccessKey``SecretKey``Token`
- 凭据会写入 JSON 文件,后续 OpenAI 兼容接口自动读取
- 按插件 v1.4.2 流程调用 `/api/acepilot/zhanlu/v1/login`RSA+HmacSHA1 签名 URL + `plugin_type=zhanlu_ide` 请求头)获取用户资料(email/组织/团队)
- 用 SM2 私钥签名调用 `{mobileModelBaseUrl}/user/api/v2/external/key/get-or-create` 换取模型 `apiKey`
- 凭据(含 `apiKey``modelBaseUrl`、email 等)会写入本地 SQLite 数据库(`zhanlu.db`),后续 OpenAI 兼容接口自动使用。
默认保存当前执行目录:
默认数据库保存当前执行目录:
```text
credentials.json
zhanlu.db
```
可以通过环境变量覆盖:
```powershell
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json"
$env:ZHANLU_DB_FILE="E:\path\to\zhanlu.db"
```
手机号验证码登录使用 `ZHANLU_SERVER_BASE_URL`,默认公网地址来自插件配置:
凭据仅持久化在数据库中,不再使用 JSON 文件。
手机号验证码登录使用 `ZHANLU_MOBILE_LOGIN_BASE_URL`,默认公网地址来自插件配置(兼容旧环境变量 `ZHANLU_SERVER_BASE_URL`):
```powershell
$env:ZHANLU_SERVER_BASE_URL="https://api-wuxi-1.cmecloud.cn:8443"
$env:ZHANLU_MOBILE_LOGIN_BASE_URL="https://ecloud.10086.cn"
$env:ZHANLU_MOBILE_MODEL_BASE_URL="https://ecloud.10086.cn/api/query/aigateway"
```
四共 SSO 的 `/auth/start``/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
灵犀(内网)SSO 的 `/auth/start``/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
## OpenAI 兼容接口
@@ -145,7 +154,7 @@ curl http://127.0.0.1:8080/v1/models
```powershell
curl http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}'
-d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":false}'
```
流式:
@@ -153,13 +162,37 @@ curl http://127.0.0.1:8080/v1/chat/completions `
```powershell
curl -N http://127.0.0.1:8080/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}'
-d '{"model":"zhanlu/auto","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
### 工具调用
请求中的 `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
@@ -176,21 +209,25 @@ curl http://127.0.0.1:8080/v1/models `
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 |
| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL |
| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 |
| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
| `ZHANLU_MOBILE_LOGIN_BASE_URL` | `https://ecloud.10086.cn` | 移动云公网登录 Base URL(兼容旧变量 `ZHANLU_SERVER_BASE_URL` |
| `ZHANLU_MOBILE_MODEL_BASE_URL` | `https://ecloud.10086.cn/api/query/aigateway` | 移动云公网模型网关 Base URL |
| `ZHANLU_UPSTREAM_PATH` | `/chat/completions` | 模型网关聊天接口路径 |
| `ZHANLU_DB_FILE` | `zhanlu.db` | 本地 SQLite 数据库路径,凭据与 token 统计均存于此,默认当前执行目录 |
| `ZHANLU_STATS_DISABLED` | `false` | 设为 `true` 关闭 token 用量记录(仅停止写入统计,凭据存储不受影响) |
| `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey |
| `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 |
| `ZHANLU_API_KEY` | 空 | 直接从环境变量提供已换取的模型 API Key |
| `ZHANLU_SSO_BASE_URL` | `http://4c.hq.cmcc` | 灵犀内网 SSO 备用页面 Base URL,非默认手机号登录流程 |
| `ZHANLU_SSO_EXCHANGE_URL` | `http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken` | 灵犀 SSO 换取用户资料的备用接口 |
| `ZHANLU_TOKEN_DECRYPT_KEY` | `3jw7woww2rvhla6k` | 解密 SSO 返回用户资料字段的 AES key(插件内置默认值) |
| `ZHANLU_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 |
| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 |
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
| `ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY` | 插件内置 SM2 私钥 | 换取模型 API Key 时 `X-Auth-Signature` 使用的 SM2 私钥,通常不需要设置 |
| `ZHANLU_PLUGIN_VERSION` | `1.4.2` | 请求 `plugin_version` |
| `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 |
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
| `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 |
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
@@ -198,25 +235,59 @@ curl http://127.0.0.1:8080/v1/models `
服务启动时按以下优先级加载凭据:
1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN`
2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件
1. 环境变量 `ZHANLU_ACCESS_KEY``ZHANLU_SECRET_KEY``ZHANLU_TOKEN``ZHANLU_API_KEY`
2. `ZHANLU_DB_FILE` 数据库中持久化的凭据行
登录页面保存后,运行中的服务会立即使用新凭据。
登录页面保存后,运行中的服务会立即使用新凭据并写入数据库。若环境中只有 AK/SK/Token 而没有 `apiKey`,首次调用聊天接口时会自动按插件流程换取 API Key 并回写数据库
## Token 消耗统计
代理在 `/v1/chat/completions` 完成后解析上游 `usage`(流式路径在透传 SSE 的同时旁路解析末块 `usage`,非流式路径在聚合时解析),将以下维度写入 `ZHANLU_DB_FILE`
- 时间、模型、流式/非流式
- `prompt_tokens` / `completion_tokens` / `total_tokens` / `reasoning_tokens`(思考模型)
- `cached_tokens`(来自 `prompt_tokens_details.cached_tokens`,提示缓存命中的 token 数)与缓存命中率
- 请求状态(`success` / `upstream_error`)与耗时
缓存命中率为 `cached_tokens / prompt_tokens`,仅在模型/网关支持 prompt 缓存且上游返回 `cached_tokens` 时非零。
管理页(需先登录管理页面)提供:
- `GET /admin/stats`:可视化页面,展示总览、按模型、按日柱状、最近请求明细,带重置按钮。
- `GET /api/stats`JSON 接口,支持 `since`/`until`RFC3339)、`model``limit` 查询参数。
- `POST /api/stats/reset`:清空统计(凭据不受影响)。
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
## 可用模型
管理后台「可用模型」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`,请不要提交到仓库。
- 默认保存在当前执行目录的 `credentials.json`
- `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
- 默认保存在当前执行目录的 `zhanlu.db`(建议通过 `ZHANLU_DB_FILE` 指向受保护路径)
- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey``authorization``Signature`
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
## 已知限制
- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖
- 灵犀内网 SSO`/auth/start``/auth/callback`)为备用接口,主流程是移动云手机号验证码登录
- 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。
- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。
- UI 模型名 `glm4.7``minimax-m2.5` 会按插件逻辑映射为上游 `glm47``minimax-m25`
- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。
- 模型网关对 OpenAI `stream:false` 请求由代理负责聚合流式响应。
## 验证
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
func main() {
@@ -14,7 +15,20 @@ func main() {
log.Fatal(err)
}
h := server.New(cfg)
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer st.Close()
// Credential precedence at startup: environment variables > db row.
if cfg.Credentials.Validate() != nil && !cfg.Credentials.HasAPIKey() {
if dbCreds, derr := st.LoadCredentials(); derr == nil && (dbCreds.HasAPIKey() || dbCreds.Validate() == nil) {
cfg.Credentials = dbCreds
}
}
h := server.New(cfg, st)
log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr)
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
+17 -1
View File
@@ -1,3 +1,19 @@
module git.misaka.ren/M1saka/zhanlu_proxy
go 1.22
go 1.25.0
require github.com/emmansun/gmsm v0.44.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.56.0 // indirect
)
+24
View File
@@ -0,0 +1,24 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emmansun/gmsm v0.44.1 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4=
github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+8 -19
View File
@@ -33,17 +33,16 @@ func DecryptCredential(ciphertextBase64, key string) (string, error) {
return string(plain), nil
}
func EncryptCredential(plaintext, key string) (string, error) {
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
if err != nil {
return "", err
// DecryptCredentialOrRaw mirrors the plugin's z4A: try AES-ECB decrypt with the
// given key, falling back to the raw value when the field is plaintext.
func DecryptCredentialOrRaw(value, key string) string {
if value == "" {
return ""
}
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
out := make([]byte, len(plain))
for start := 0; start < len(plain); start += aes.BlockSize {
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
if plain, err := DecryptCredential(value, key); err == nil && plain != "" {
return plain
}
return base64.StdEncoding.EncodeToString(out), nil
return value
}
func repeatKey(key string, size int) []byte {
@@ -73,13 +72,3 @@ func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
}
return in[:len(in)-pad], nil
}
func padPKCS7(in []byte, blockSize int) []byte {
pad := blockSize - len(in)%blockSize
out := make([]byte, len(in)+pad)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+23 -33
View File
@@ -1,23 +1,36 @@
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
type Credentials struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Token string `json:"token"`
APIKey string `json:"api_key,omitempty"`
ModelBaseURL string `json:"model_base_url,omitempty"`
Email string `json:"email,omitempty"`
Organization string `json:"organization,omitempty"`
Team string `json:"team,omitempty"`
BaseURL string `json:"base_url,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
type Profile struct {
Email string
Organization string
Team string
UserName string
Telephone string
}
func (c Credentials) Validate() error {
if strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" {
return nil
}
if strings.TrimSpace(c.AccessKey) == "" {
return errors.New("access_key is required")
}
@@ -30,29 +43,6 @@ func (c Credentials) Validate() error {
return nil
}
func LoadCredentials(path string) (Credentials, error) {
b, err := os.ReadFile(path)
if err != nil {
return Credentials{}, err
}
var c Credentials
if err := json.Unmarshal(b, &c); err != nil {
return Credentials{}, err
}
return c, c.Validate()
}
func SaveCredentials(path string, c Credentials) error {
if err := c.Validate(); err != nil {
return err
}
c.SavedAt = time.Now()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, b, 0o600)
func (c Credentials) HasAPIKey() bool {
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
}
+36
View File
@@ -0,0 +1,36 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
)
// SignSM2Authorization signs `message` with the SM2 private key in hex form
// (mirroring the Zhanlu plugin: SM3 digest signed with hash:false, der:false,
// output as 64-byte r||s hex).
func SignSM2Authorization(privateKeyHex, message string) (string, error) {
keyHex := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
return "", err
}
priv, err := sm2.NewPrivateKey(keyBytes)
if err != nil {
return "", err
}
digest := sm3.Sum([]byte(message))
r, s, err := sm2.Sign(rand.Reader, &priv.PrivateKey, digest[:])
if err != nil {
return "", err
}
rb := r.FillBytes(make([]byte, 32))
sb := s.FillBytes(make([]byte, 32))
return hex.EncodeToString(append(rb, sb...)), nil
}
var errEmptySM2Key = errors.New("SM2 private key is required")
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"encoding/hex"
"strings"
"testing"
)
func TestSignSM2Authorization(t *testing.T) {
const privHex = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
sig, err := SignSM2Authorization(privHex, "1754460000:AbCdEfGh1234567890AbCdEfGh123456:{\"email\":\"[email protected]\"}")
if err != nil {
t.Fatalf("SignSM2Authorization: %v", err)
}
if len(sig) != 128 {
t.Fatalf("signature length = %d, want 128 (r||s hex)", len(sig))
}
if _, err := hex.DecodeString(sig); err != nil {
t.Fatalf("signature is not hex: %v", err)
}
// Deterministic inputs must produce a stable signature across calls only if
// the nonce is fixed; sm-crypto randomizes k, so just check shape + parse.
if strings.TrimSpace(sig) != sig {
t.Fatalf("signature contains whitespace")
}
}
func TestSignSM2AuthorizationInvalidKey(t *testing.T) {
if _, err := SignSM2Authorization("zz", "x"); err == nil {
t.Fatal("expected error for invalid private key hex")
}
}
+68 -53
View File
@@ -5,86 +5,101 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
Body map[string]any `json:"body"`
}
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
// gateway authToken endpoint (POST /api/acepilot/zhanlu/authToken). The
// profile fields may be AES-ECB encrypted with the token decrypt key; each
// field falls back to the raw value when decryption fails.
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Profile, error) {
if strings.TrimSpace(endpoint) == "" {
return Credentials{}, errors.New("exchange endpoint is required")
return Profile{}, errors.New("exchange endpoint is required")
}
if strings.TrimSpace(code) == "" {
return Credentials{}, errors.New("code is required")
}
if strings.TrimSpace(decryptKey) == "" {
return Credentials{}, errors.New("decrypt key is required")
return Profile{}, errors.New("code is required")
}
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
// HTTP/1.1 only: the Zhanlu gateway drops HTTP/2 negotiation.
client = &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ForceAttemptHTTP2: false}}
}
body, _ := json.Marshal(map[string]string{"code": code})
body, _ := json.Marshal(map[string]string{"deputyAccountNumber": code})
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Credentials{}, err
return Profile{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return Credentials{}, err
return Profile{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var exchange ExchangeResponse
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
return Credentials{}, err
return Profile{}, err
}
if exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
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 := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
if err != nil {
return Credentials{}, err
}
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
if err != nil {
return Credentials{}, err
}
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
if err != nil {
return Credentials{}, err
}
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
}
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
v, ok := body[key]
if !ok {
return "", fmt.Errorf("response body missing %s", key)
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("response body %s is not a string", key)
}
return DecryptCredential(strings.TrimSpace(s), decryptKey)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
profile := Profile{}
for _, m := range []map[string]any{exchange.Body, exchange.Result, exchange.Data} {
if m == nil {
continue
}
profile.Email = decryptProfileField(m, "email", decryptKey)
profile.Organization = decryptProfileField(m, "organization", decryptKey)
profile.Team = decryptProfileField(m, "team", decryptKey)
profile.UserName = decryptProfileField(m, "name", decryptKey)
profile.Telephone = decryptProfileField(m, "telephone", decryptKey)
if profile.Email != "" || profile.Organization != "" || profile.Team != "" {
return profile, nil
}
}
return "unknown error"
return Profile{}, errors.New("exchange response body missing profile fields")
}
func decryptProfileField(m map[string]any, key, decryptKey string) string {
v, ok := m[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return DecryptCredentialOrRaw(strings.TrimSpace(s), decryptKey)
}
type ExchangeResponse struct {
ErrorCode string `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
Message string `json:"message"`
State string `json:"state"`
Body map[string]any `json:"body"`
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
+54
View File
@@ -0,0 +1,54 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExchangeCodeAuthTokenFlow(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/authToken", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s", r.Method)
}
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
if in["deputyAccountNumber"] != "dep-123" {
t.Errorf("deputyAccountNumber = %q", in["deputyAccountNumber"])
}
// plaintext profile (DecryptCredentialOrRaw fallback)
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "org", "team": "team",
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
profile, err := ExchangeCode(&http.Client{}, ts.URL+"/api/acepilot/zhanlu/authToken", "dep-123", "3jw7woww2rvhla6k")
if err != nil {
t.Fatalf("ExchangeCode: %v", err)
}
if profile.Email != "[email protected]" || profile.Organization != "org" || profile.Team != "team" {
t.Fatalf("profile = %+v", profile)
}
}
func TestExchangeCodeMissingFields(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) {
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
if _, err := ExchangeCode(&http.Client{}, ts.URL+"/x", "dep", ""); err == nil {
t.Fatal("expected error for empty profile")
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
+43 -54
View File
@@ -2,61 +2,64 @@ 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 {
ListenAddr string
ServerBaseURL string
UpstreamPath string
CredentialsPath string
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
Models []string
DefaultModel string
OpenAIAPIKey string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
Debug bool
Credentials auth.Credentials
ListenAddr string
MobileLoginBaseURL string
MobileModelBaseURL string
UpstreamPath string
DBPath string
StatsDisabled bool
SSOExchangeURL string
SSOBaseURL string
TokenDecryptKey string
PublicKeyPEM string
PhonePublicKeyPEM string
SM2PrivateKey string
OpenAIAPIKey string
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimeout time.Duration
Debug bool
Credentials auth.Credentials
}
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"),
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"),
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
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"),
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
StatsDisabled: strings.EqualFold(os.Getenv("ZHANLU_STATS_DISABLED"), "true"),
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://4c.hq.cmcc"),
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "http://rdcloud.4c.hq.cmcc/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/authToken"),
TokenDecryptKey: getenv("ZHANLU_TOKEN_DECRYPT_KEY", "3jw7woww2rvhla6k"),
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
SM2PrivateKey: getenv("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY", defaultSM2PrivateKey),
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "glm47,minimax-m25"))
cfg.Credentials = auth.Credentials{
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
Token: os.Getenv("ZHANLU_TOKEN"),
APIKey: os.Getenv("ZHANLU_API_KEY"),
}
if cfg.Credentials.Validate() == nil {
return cfg, nil
}
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
cfg.Credentials = creds
}
// Credentials from the environment were incomplete; the store serves the
// persisted row at runtime.
return cfg, nil
}
@@ -67,18 +70,6 @@ func getenv(key, fallback string) string {
return fallback
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func durationEnv(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
@@ -91,10 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d
}
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-----`
@@ -102,3 +89,5 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUM
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
-----END PUBLIC KEY-----`
const defaultSM2PrivateKey = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
+396
View File
@@ -0,0 +1,396 @@
package openai
import (
"encoding/json"
"errors"
"strings"
)
// --- 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 {
inner["arguments"] = rawJSONToString(v)
}
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 {
msg["content"] = outputToString(v)
}
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
}
// rawJSONToString converts a json.RawMessage to a string. If the value is
// already a JSON string, it is used directly. Any other JSON value (object,
// array, number, bool) is re-encoded as a JSON string so it can populate
// fields that require a string, such as tool_calls[].function.arguments.
// This prevents silent data loss when a field arrives as a non-string type.
func rawJSONToString(v json.RawMessage) string {
var s string
if json.Unmarshal(v, &s) == nil {
return s
}
var anyValue any
if json.Unmarshal(v, &anyValue) == nil {
if b, err := json.Marshal(anyValue); err == nil {
return string(b)
}
}
return string(v) // last resort: raw bytes
}
// outputToString converts a function_call_output "output" value to a string
// for the Chat Completions tool message content. The output may be:
// - a plain string (used directly)
// - an array of content parts (text extracted and concatenated)
// - any other JSON value (re-encoded as a JSON string)
func outputToString(v json.RawMessage) string {
var s string
if json.Unmarshal(v, &s) == nil {
return s
}
// Try array of content parts — extract text from each part.
var parts []map[string]any
if json.Unmarshal(v, &parts) == nil {
var sb strings.Builder
for _, p := range parts {
if t, _ := p["text"].(string); t != "" {
sb.WriteString(t)
}
}
if sb.Len() > 0 {
return sb.String()
}
}
// Fallback: re-encode as a JSON string.
var anyValue any
if json.Unmarshal(v, &anyValue) == nil {
if b, err := json.Marshal(anyValue); err == nil {
return string(b)
}
}
return string(v)
}
// 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
}
+389
View File
@@ -0,0 +1,389 @@
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"])
}
}
// TestParseResponsesRequest_FunctionCallObjectArguments verifies that when
// function_call.arguments arrives as a JSON object (not a string), it is
// re-encoded as a JSON string instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallObjectArguments(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_456","name":"task","arguments":{"operation":"create","summary":"test"}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
tc, _ := req.Messages[0]["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("tool_calls = %v", req.Messages[0]["tool_calls"])
}
fn, _ := tc[0].(map[string]any)["function"].(map[string]any)
args, _ := fn["arguments"].(string)
if args == "" {
t.Fatalf("arguments was dropped (empty)")
}
// The re-encoded string must be valid JSON containing the original fields.
var parsed map[string]any
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
t.Fatalf("arguments not valid JSON: %v", err)
}
if parsed["operation"] != "create" {
t.Fatalf("operation = %v", parsed["operation"])
}
}
// TestParseResponsesRequest_FunctionCallOutputArray verifies that when
// function_call_output.output arrives as an array of content parts, the
// text is extracted instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallOutputArray(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_789","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
{"type":"function_call_output","call_id":"call_789","output":[{"type":"output_text","text":"sunny 72F"}]}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("messages = %d items", len(req.Messages))
}
toolMsg := req.Messages[1]
if toolMsg["role"] != "tool" {
t.Fatalf("msg[1] role = %v", toolMsg["role"])
}
content, _ := toolMsg["content"].(string)
if content != "sunny 72F" {
t.Fatalf("content = %q, want %q", content, "sunny 72F")
}
}
// TestParseResponsesRequest_FunctionCallOutputObject verifies that when
// function_call_output.output is a bare JSON object, it is re-encoded as a
// JSON string instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallOutputObject(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_obj","name":"run","arguments":"{}"},
{"type":"function_call_output","call_id":"call_obj","output":{"result":"success","code":200}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
toolMsg := req.Messages[1]
content, _ := toolMsg["content"].(string)
if content == "" {
t.Fatal("content was dropped (empty)")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
t.Fatalf("content not valid JSON: %v", err)
}
if parsed["result"] != "success" {
t.Fatalf("result = %v", parsed["result"])
}
}
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)
}
}
+192 -13
View File
@@ -1,6 +1,9 @@
package openai
import "encoding/json"
import (
"encoding/json"
"strings"
)
type ChatCompletionRequest struct {
Model string `json:"model"`
@@ -28,21 +31,11 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
}
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
model := map[string]string{
"minimax-m2.5": "minimax-m25",
"glm4.7": "glm47",
}[r.Model]
if model == "" {
model = r.Model
}
m := map[string]any{
"model": model,
"messages": r.Messages,
"temperature": 0,
"model": r.Model,
"messages": normalizeMessages(r.Messages, extractToolNames(r.Extra)),
"stream": r.Stream,
"stream_options": map[string]any{"include_usage": true},
"max_tokens": 16000,
"inputs": map[string]any{"aiDevQuestion": ""},
}
for k, v := range r.Extra {
var anyValue any
@@ -50,9 +43,195 @@ func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
m[k] = anyValue
}
}
// Mirror the Zhanlu plugin: GLM models require {tool_stream:true} to
// stream tool calls back to the client (matches /glm/i.test(model)).
if strings.Contains(strings.ToLower(r.Model), "glm") {
m["tool_stream"] = true
}
return json.Marshal(m)
}
// extractToolNames returns the set of function names declared in the tools
// array (Extra["tools"]), used to validate tool_calls in the message history.
func extractToolNames(extra map[string]json.RawMessage) map[string]bool {
names := map[string]bool{}
raw, ok := extra["tools"]
if !ok {
return names
}
var tools []any
if json.Unmarshal(raw, &tools) != nil {
return names
}
for _, t := range tools {
tm, ok := t.(map[string]any)
if !ok {
continue
}
// Chat Completions format: {type:"function",function:{name:"…"}}
if fn, ok := tm["function"].(map[string]any); ok {
if name, _ := fn["name"].(string); name != "" {
names[name] = true
}
}
// Responses API flat format: {type:"function",name:"…"}
if name, _ := tm["name"].(string); name != "" {
names[name] = true
}
}
return names
}
// normalizeMessages prepares the message history for the Zhanlu upstream
// gateway. It performs two normalizations:
//
// 1. Flattens OpenAI multi-part content — an array of {type:"text",text:"…"}
// parts — into a plain string. The gateway only accepts string content
// and returns HTTP 400 for arrays.
//
// 2. Sanitizes tool_calls that the gateway would reject: calls whose
// function name is not in the declared tools set (e.g. "invalid"
// placeholders from AI coding tools when a tool call fails), or whose
// arguments are not a valid JSON object. Instead of removing these
// calls (which would lose error feedback the model needs), the function
// name is replaced with a valid one and the arguments are replaced with
// "{}". The corresponding tool result messages are preserved so the
// model still sees the full conversation including errors.
//
// Both []any (from JSON decoding of /v1/chat/completions) and
// []map[string]any (from convertContentParts in the Responses API path)
// are handled for content arrays.
func normalizeMessages(messages []map[string]any, validToolNames map[string]bool) []map[string]any {
fallbackName := pickFallbackToolName(validToolNames)
for _, msg := range messages {
// --- flatten multi-part text content ---
parts := contentAsAnySlice(msg["content"])
if len(parts) > 0 {
var sb strings.Builder
allText := true
for _, p := range parts {
part, ok := p.(map[string]any)
if !ok {
allText = false
break
}
pt, _ := part["type"].(string)
if pt != "text" && pt != "input_text" && pt != "output_text" {
allText = false
break
}
text, _ := part["text"].(string)
sb.WriteString(text)
}
if allText {
msg["content"] = sb.String()
}
}
// --- sanitize tool_calls in place ---
tcs := contentAsAnySlice(msg["tool_calls"])
for _, tc := range tcs {
if tcMap, ok := tc.(map[string]any); ok {
sanitizeToolCall(tcMap, validToolNames, fallbackName)
}
}
}
return messages
}
// sanitizeToolCall fixes a tool_call in place so the upstream gateway
// accepts it. Two fields are corrected:
//
// - function.name: if the name is not among the declared tools (when the
// set is non-empty), it is replaced. The replacement is extracted from
// the arguments' "tool" field (MiMoCode stores the real tool name there
// in error placeholders); failing that, an arbitrary declared tool name
// is used.
//
// - function.arguments: if the value is not a valid JSON object string
// (e.g. "-1", "", "true"), it is replaced with "{}".
//
// The tool_call id, type, and the tool result messages are left untouched,
// preserving the full conversation context for the model.
func sanitizeToolCall(tc map[string]any, validToolNames map[string]bool, fallbackName string) {
fn, ok := tc["function"].(map[string]any)
if !ok {
return
}
// Fix function name if not in the valid set.
name, _ := fn["name"].(string)
if len(validToolNames) > 0 && !validToolNames[name] {
args, _ := fn["arguments"].(string)
if extracted := extractToolNameFromArgs(args); validToolNames[extracted] {
fn["name"] = extracted
} else if fallbackName != "" {
fn["name"] = fallbackName
}
}
// Fix arguments if not a valid JSON object.
args, _ := fn["arguments"].(string)
if !isValidJSONObject(args) {
fn["arguments"] = "{}"
}
}
// extractToolNameFromArgs attempts to read a "tool" field from the JSON
// object in args. MiMoCode's error placeholders store the real tool name
// here (e.g. {"tool":"task","error":"…"}).
func extractToolNameFromArgs(args string) string {
var m map[string]any
if json.Unmarshal([]byte(args), &m) != nil {
return ""
}
tool, _ := m["tool"].(string)
return tool
}
// pickFallbackToolName returns an arbitrary name from the set for use as a
// last-resort replacement when no real tool name can be extracted.
func pickFallbackToolName(names map[string]bool) string {
for name := range names {
return name
}
return ""
}
// isValidJSONObject reports whether s is a non-empty JSON string that decodes
// to a JSON object (map[string]any). The OpenAI tool_call arguments field
// must be a JSON object string like {"key":"value"}; the Zhanlu gateway
// rejects values like "-1", "true", or "" with HTTP 400.
func isValidJSONObject(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
return false
}
_, ok := v.(map[string]any)
return ok
}
// contentAsAnySlice returns the content value as a []any, handling both
// []any (JSON decoding) and []map[string]any (convertContentParts). It
// returns nil for non-slice values (strings, nil, etc.).
func contentAsAnySlice(v any) []any {
switch s := v.(type) {
case []any:
return s
case []map[string]any:
result := make([]any, len(s))
for i, m := range s {
result[i] = m
}
return result
default:
return nil
}
}
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
+486
View File
@@ -0,0 +1,486 @@
package openai
import (
"encoding/json"
"strings"
"testing"
)
func TestMarshalForUpstream_StringContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Messages: []map[string]any{{"role": "user", "content": "hello"}},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
if content, _ := msg["content"].(string); content != "hello" {
t.Fatalf("content = %v, want %q", msg["content"], "hello")
}
}
// TestMarshalForUpstream_FlattensArrayContent verifies that multi-part
// content arrays (the format used by OpenAI SDKs and AI coding tools) are
// flattened into a plain string so the Zhanlu upstream gateway accepts the
// request instead of returning HTTP 400.
func TestMarshalForUpstream_FlattensArrayContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "zhanlu/deepseek-v4-pro",
Messages: []map[string]any{
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "first part "},
map[string]any{"type": "text", "text": "second part"},
}},
},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
content, ok := msg["content"].(string)
if !ok {
t.Fatalf("content type = %T, want string", msg["content"])
}
if content != "first part second part" {
t.Fatalf("content = %q, want %q", content, "first part second part")
}
}
// TestMarshalForUpstream_PreservesNonTextArrayContent verifies that content
// arrays containing non-text parts (e.g. images) are left intact rather than
// being flattened, so the upstream can handle multimodal content.
func TestMarshalForUpstream_PreservesNonTextArrayContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Messages: []map[string]any{
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe this"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,abc"}},
}},
},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
parts, ok := msg["content"].([]any)
if !ok {
t.Fatalf("content type = %T, want []any (preserved array)", msg["content"])
}
if len(parts) != 2 {
t.Fatalf("parts = %d, want 2", len(parts))
}
}
// TestMarshalForUpstream_ExtraFields verifies that extra fields (max_tokens,
// tools, etc.) are passed through to the upstream body.
func TestMarshalForUpstream_ExtraFields(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Stream: true,
Extra: map[string]json.RawMessage{
"max_tokens": json.RawMessage(`32000`),
"tool_choice": json.RawMessage(`"auto"`),
},
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
if v, _ := m["max_tokens"].(float64); v != 32000 {
t.Fatalf("max_tokens = %v, want 32000", m["max_tokens"])
}
if v, _ := m["tool_choice"].(string); v != "auto" {
t.Fatalf("tool_choice = %v, want \"auto\"", m["tool_choice"])
}
}
// TestMarshalForUpstream_GLMToolStream verifies that tool_stream is added
// for GLM models but not for non-GLM models.
func TestMarshalForUpstream_GLMToolStream(t *testing.T) {
cases := []struct {
model string
wantTool bool
}{
{"GLM-4.7", true},
{"zhanlu/glm-4-pro", true},
{"zhanlu/deepseek-v4-pro", false},
{"deepseek-chat", false},
}
for _, tc := range cases {
req := ChatCompletionRequest{Model: tc.model, Stream: true}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
_ = json.Unmarshal(body, &m)
_, has := m["tool_stream"]
if has != tc.wantTool {
t.Fatalf("model %q: tool_stream present = %v, want %v", tc.model, has, tc.wantTool)
}
}
}
// TestNormalizeMessages_MixedContent verifies a mix of string and array
// content across multiple messages in a single request.
func TestNormalizeMessages_MixedContent(t *testing.T) {
messages := []map[string]any{
{"role": "system", "content": "you are helpful"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "part 1 "},
map[string]any{"type": "text", "text": "part 2"},
}},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "only part"},
}},
}
result := normalizeMessages(messages, nil)
// system message unchanged
if c, _ := result[0]["content"].(string); c != "you are helpful" {
t.Fatalf("msg[0] content = %v", result[0]["content"])
}
// array content flattened
if c, _ := result[1]["content"].(string); c != "part 1 part 2" {
t.Fatalf("msg[1] content = %v, want %q", result[1]["content"], "part 1 part 2")
}
// string content unchanged
if c, _ := result[2]["content"].(string); c != "ok" {
t.Fatalf("msg[2] content = %v", result[2]["content"])
}
// single-part array flattened to string
if c, _ := result[3]["content"].(string); c != "only part" {
t.Fatalf("msg[3] content = %v, want %q", result[3]["content"], "only part")
}
}
// TestNormalizeMessages_EmptyArray verifies that an empty content array
// becomes an empty string.
func TestNormalizeMessages_EmptyArray(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": []any{}},
}
result := normalizeMessages(messages, nil)
if c, _ := result[0]["content"].(string); c != "" {
t.Fatalf("content = %v, want empty string", result[0]["content"])
}
}
// TestNormalizeMessages_MapSliceContent verifies that content stored as
// []map[string]any (produced by convertContentParts in the Responses API
// path) is also flattened, not just []any (from JSON decoding).
func TestNormalizeMessages_MapSliceContent(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": []map[string]any{
{"type": "text", "text": "map part 1 "},
{"type": "text", "text": "map part 2"},
}},
}
result := normalizeMessages(messages, nil)
if c, _ := result[0]["content"].(string); c != "map part 1 map part 2" {
t.Fatalf("content = %v, want %q", result[0]["content"], "map part 1 map part 2")
}
}
// TestMarshalForUpstream_ErrorRequestReplay is a regression test mirroring
// the real failing request from error.md: a multi-turn conversation with
// system prompt, user messages with multi-part content arrays, assistant
// messages with tool_calls and reasoning_content, and tool result messages.
// It verifies that no message has array content after marshaling.
func TestMarshalForUpstream_ErrorRequestReplay(t *testing.T) {
req := ChatCompletionRequest{
Model: "zhanlu/deepseek-v4-pro",
Messages: []map[string]any{
{"role": "system", "content": strings.Repeat("system prompt ", 100)},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "user question"},
map[string]any{"type": "text", "text": "<system-reminder>skill search</system-reminder>"},
}},
{"role": "assistant", "content": "I will search.", "reasoning_content": "thinking...", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "file1\nfile2"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "continue"},
}},
},
Stream: true,
Extra: map[string]json.RawMessage{
"max_tokens": json.RawMessage(`32000`),
"tool_choice": json.RawMessage(`"auto"`),
},
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
for i, raw := range messages {
msg, _ := raw.(map[string]any)
switch c := msg["content"].(type) {
case string:
// OK — flattened
case []any:
t.Fatalf("message [%d] still has array content after marshal", i)
default:
t.Fatalf("message [%d] content type = %T", i, c)
}
}
}
// TestNormalizeMessages_SanitizesInvalidToolCallNames verifies that
// tool_calls whose function name is not in the declared tools set are
// fixed in place — the name is replaced with a valid one — rather than
// removed. This preserves the conversation context including error
// feedback. MiMoCode emits {name:"invalid"} placeholders when a tool
// call fails; the Zhanlu gateway rejects unknown function names with
// HTTP 400.
func TestNormalizeMessages_SanitizesInvalidToolCallNames(t *testing.T) {
validNames := map[string]bool{"bash": true, "read": true}
messages := []map[string]any{
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "I'll run bash.", "tool_calls": []any{
map[string]any{"id": "call_ok", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
// "invalid" name with "tool" field in args — should be extracted
map[string]any{"id": "call_bad", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"read","error":"failed"}`}},
}},
{"role": "tool", "tool_call_id": "call_ok", "content": "file1"},
{"role": "tool", "tool_call_id": "call_bad", "content": "error result"},
{"role": "user", "content": "thanks"},
}
result := normalizeMessages(messages, validNames)
// All messages preserved (no removal)
if len(result) != 5 {
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
}
// Both tool_calls kept; the invalid one's name should be fixed to "read"
// (extracted from args.tool)
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 2 {
t.Fatalf("tool_calls = %d items, want 2 (preserved)", len(tcs))
}
tc1, _ := tcs[0].(map[string]any)
fn1, _ := tc1["function"].(map[string]any)
if name, _ := fn1["name"].(string); name != "bash" {
t.Fatalf("first tool_call name = %q, want %q", name, "bash")
}
tc2, _ := tcs[1].(map[string]any)
fn2, _ := tc2["function"].(map[string]any)
if name, _ := fn2["name"].(string); name != "read" {
t.Fatalf("sanitized tool_call name = %q, want %q", name, "read")
}
// Tool results preserved
toolMsg := result[3]
if id, _ := toolMsg["tool_call_id"].(string); id != "call_bad" {
t.Fatalf("tool_call_id = %q, want %q", id, "call_bad")
}
}
// TestNormalizeMessages_SanitizesInvalidArguments verifies that tool_calls
// whose arguments are not a valid JSON object string (e.g. "-1", "",
// "true") are fixed to "{}" in place, not removed. The Zhanlu gateway
// requires arguments to be a JSON object.
func TestNormalizeMessages_SanitizesInvalidArguments(t *testing.T) {
validNames := map[string]bool{"task": true}
messages := []map[string]any{
{"role": "user", "content": "create tasks"},
{"role": "assistant", "content": "creating.", "tool_calls": []any{
map[string]any{"id": "call_good", "type": "function", "function": map[string]any{"name": "task", "arguments": `{"operation":"create","summary":"test"}`}},
map[string]any{"id": "call_bad1", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
map[string]any{"id": "call_bad2", "type": "function", "function": map[string]any{"name": "task", "arguments": ""}},
}},
{"role": "tool", "tool_call_id": "call_good", "content": "created"},
{"role": "tool", "tool_call_id": "call_bad1", "content": "error"},
{"role": "tool", "tool_call_id": "call_bad2", "content": "error"},
}
result := normalizeMessages(messages, validNames)
// All 5 messages preserved
if len(result) != 5 {
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 3 {
t.Fatalf("tool_calls = %d items, want 3 (preserved)", len(tcs))
}
// Good args unchanged
tc0, _ := tcs[0].(map[string]any)
fn0, _ := tc0["function"].(map[string]any)
if args, _ := fn0["arguments"].(string); args != `{"operation":"create","summary":"test"}` {
t.Fatalf("good args changed: %q", args)
}
// Bad args fixed to "{}"
tc1, _ := tcs[1].(map[string]any)
fn1, _ := tc1["function"].(map[string]any)
if args, _ := fn1["arguments"].(string); args != "{}" {
t.Fatalf("bad1 args = %q, want {}", args)
}
tc2, _ := tcs[2].(map[string]any)
fn2, _ := tc2["function"].(map[string]any)
if args, _ := fn2["arguments"].(string); args != "{}" {
t.Fatalf("bad2 args = %q, want {}", args)
}
}
// TestNormalizeMessages_KeepsValidToolCalls verifies that valid tool_calls
// (correct name and valid JSON object arguments) are preserved unchanged.
func TestNormalizeMessages_KeepsValidToolCalls(t *testing.T) {
validNames := map[string]bool{"bash": true, "read": true}
messages := []map[string]any{
{"role": "user", "content": "list files"},
{"role": "assistant", "content": "sure.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "read", "arguments": `{"file":"a.txt"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "file1"},
{"role": "tool", "tool_call_id": "call_2", "content": "content"},
}
result := normalizeMessages(messages, validNames)
if len(result) != 4 {
t.Fatalf("messages = %d, want 4", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 2 {
t.Fatalf("tool_calls = %d items, want 2", len(tcs))
}
// Verify the first tool_call is unchanged
tc, _ := tcs[0].(map[string]any)
fn, _ := tc["function"].(map[string]any)
if name, _ := fn["name"].(string); name != "bash" {
t.Fatalf("name = %q, want %q", name, "bash")
}
if args, _ := fn["arguments"].(string); args != `{"command":"ls"}` {
t.Fatalf("args = %q, want %q", args, `{"command":"ls"}`)
}
}
// TestNormalizeMessages_NoToolNamesSkipsNameCheck verifies that when no
// tools are declared (empty map), tool_calls are not name-sanitized
// (only argument validity is checked).
func TestNormalizeMessages_NoToolNamesSkipsNameCheck(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "ok.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "custom_fn", "arguments": `{"x":1}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
}
result := normalizeMessages(messages, nil)
if len(result) != 3 {
t.Fatalf("messages = %d, want 3", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
tc, _ := tcs[0].(map[string]any)
fn, _ := tc["function"].(map[string]any)
// Name should be unchanged (no tools to validate against)
if name, _ := fn["name"].(string); name != "custom_fn" {
t.Fatalf("name = %q, want %q (should not be changed)", name, "custom_fn")
}
}
// TestNormalizeMessages_ErrorMdScenario mirrors the exact error.md
// request: an assistant message with 5 tool_calls where 4 have
// name:"invalid" (with args containing {"tool":"task","error":"…"}) and 1
// has name:"task" with args:"-1". Verifies that after sanitization, all
// 5 calls have valid names and valid JSON object arguments.
func TestNormalizeMessages_ErrorMdScenario(t *testing.T) {
validNames := map[string]bool{"task": true, "bash": true}
messages := []map[string]any{
{"role": "user", "content": "create tasks"},
{"role": "assistant", "content": "confirming.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_3", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
map[string]any{"id": "call_4", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_5", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "error 1"},
{"role": "tool", "tool_call_id": "call_2", "content": "error 2"},
{"role": "tool", "tool_call_id": "call_3", "content": "error 3"},
{"role": "tool", "tool_call_id": "call_4", "content": "error 4"},
{"role": "tool", "tool_call_id": "call_5", "content": "error 5"},
}
result := normalizeMessages(messages, validNames)
// All 7 messages preserved (no removal)
if len(result) != 7 {
t.Fatalf("messages = %d, want 7", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 5 {
t.Fatalf("tool_calls = %d items, want 5", len(tcs))
}
for i, raw := range tcs {
tc, _ := raw.(map[string]any)
fn, _ := tc["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
// All names should be "task" (extracted from args or already valid)
if name != "task" {
t.Errorf("tool_call[%d] name = %q, want %q", i, name, "task")
}
// All args should be valid JSON objects
if !isValidJSONObject(args) {
t.Errorf("tool_call[%d] args = %q, not a valid JSON object", i, args)
}
}
}
// TestIsValidJSONObject verifies the JSON object validation used to filter
// tool_call arguments.
func TestIsValidJSONObject(t *testing.T) {
cases := []struct {
input string
want bool
}{
{`{"key":"value"}`, true},
{`{}`, true},
{`{"nested":{"a":1}}`, true},
{`-1`, false},
{`true`, false},
{`"string"`, false},
{`[1,2,3]`, false},
{``, false},
{` `, false},
{`{invalid json}`, false},
}
for _, tc := range cases {
if got := isValidJSONObject(tc.input); got != tc.want {
t.Errorf("isValidJSONObject(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"bytes"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
maxRecentEntries = 10
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
)
// RecentEntry captures a single API request and its response for debugging.
type RecentEntry struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
DurationMs int64 `json:"duration_ms"`
RequestHeaders map[string]string `json:"request_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
}
// RecentRecorder is an in-memory ring buffer that stores the most recent API
// requests. It is safe for concurrent use.
type RecentRecorder struct {
mu sync.Mutex
entries []RecentEntry
}
func NewRecentRecorder() *RecentRecorder {
return &RecentRecorder{}
}
// Record appends an entry, evicting the oldest when the buffer is full.
func (r *RecentRecorder) Record(e RecentEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
if len(r.entries) > maxRecentEntries {
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
}
}
// Entries returns a copy of the buffer in newest-first order.
func (r *RecentRecorder) Entries() []RecentEntry {
r.mu.Lock()
defer r.mu.Unlock()
n := len(r.entries)
out := make([]RecentEntry, n)
for i, e := range r.entries {
out[n-1-i] = e // reverse: newest first
}
return out
}
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
// and a truncated copy of the response body. It implements http.Flusher so
// streaming handlers can flush through the wrapper.
type recordingResponseWriter struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
totalBytes int
}
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (w *recordingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
w.totalBytes += len(b)
if w.body.Len() < maxBodyCapture {
remaining := maxBodyCapture - w.body.Len()
if len(b) <= remaining {
w.body.Write(b)
} else {
w.body.Write(b[:remaining])
}
}
return w.ResponseWriter.Write(b)
}
func (w *recordingResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// captureRequestHeaders extracts selected request headers, masking the
// Authorization value to avoid leaking API keys.
func captureRequestHeaders(r *http.Request) map[string]string {
headers := map[string]string{}
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
if v := r.Header.Get(key); v != "" {
if key == "Authorization" {
headers[key] = mask(v)
} else {
headers[key] = v
}
}
}
return headers
}
// truncateBody returns the string form of b, truncated to maxBodyCapture
// bytes with a marker if the original was longer.
func truncateBody(b []byte) string {
if len(b) > maxBodyCapture {
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// formatResponseBody returns the captured response body, with a truncation
// marker if the full response exceeded the capture limit.
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
s := buf.String()
if totalBytes > maxBodyCapture {
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
}
return s
}
// withRecording wraps a handler so that each request's headers, body,
// response status, and response body (truncated) are captured into the
// server's RecentRecorder. It is applied to the /v1/ API routes so that
// both successful and error responses are recorded.
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Read and restore the request body so the downstream handler
// still sees the full content.
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
recW := newRecordingResponseWriter(w)
next(recW, r)
s.recorder.Record(RecentEntry{
Timestamp: start,
Method: r.Method,
Path: r.URL.Path,
Status: recW.statusCode,
DurationMs: time.Since(start).Milliseconds(),
RequestHeaders: captureRequestHeaders(r),
RequestBody: truncateBody(reqBody),
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
})
}
}
// getRecent handles GET /api/recent — returns the most recent API requests
// as JSON, newest first.
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"requests": s.recorder.Entries(),
})
}
+148
View File
@@ -0,0 +1,148 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
// maxRecentEntries and evicts the oldest when full.
func TestRecentRecorderRingBuffer(t *testing.T) {
rec := NewRecentRecorder()
for i := 0; i < maxRecentEntries+5; i++ {
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
}
entries := rec.Entries()
if len(entries) != maxRecentEntries {
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
}
}
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
func TestRecentRecorderNewestFirst(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Path: "/first"})
rec.Record(RecentEntry{Path: "/second"})
rec.Record(RecentEntry{Path: "/third"})
entries := rec.Entries()
if len(entries) != 3 {
t.Fatalf("got %d entries", len(entries))
}
if entries[0].Path != "/third" {
t.Fatalf("first entry = %q, want /third", entries[0].Path)
}
if entries[2].Path != "/first" {
t.Fatalf("last entry = %q, want /first", entries[2].Path)
}
}
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
// request headers, request body, response status, and response body.
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":"ok"}`))
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
req.Header.Set("User-Agent", "test-client/1.0")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Method != "POST" {
t.Fatalf("method = %q", e.Method)
}
if e.Path != "/v1/chat/completions" {
t.Fatalf("path = %q", e.Path)
}
if e.Status != 200 {
t.Fatalf("status = %d", e.Status)
}
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
t.Fatalf("request body = %q", e.RequestBody)
}
if e.ResponseBody != `{"result":"ok"}` {
t.Fatalf("response body = %q", e.ResponseBody)
}
// Authorization must be masked
auth, ok := e.RequestHeaders["Authorization"]
if !ok || !strings.Contains(auth, "****") {
t.Fatalf("authorization not masked: %q", auth)
}
if strings.Contains(auth, "sk-secret-key-12345") {
t.Fatal("authorization leaked raw key")
}
if e.RequestHeaders["Content-Type"] != "application/json" {
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
}
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
}
}
// TestWithRecordingCapturesErrors verifies error responses are recorded.
func TestWithRecordingCapturesErrors(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
req.Header.Set("Authorization", "Bearer wrong-key")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Status != 401 {
t.Fatalf("status = %d, want 401", e.Status)
}
if !strings.Contains(e.ResponseBody, "invalid api key") {
t.Fatalf("response body = %q", e.ResponseBody)
}
}
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
func TestGetRecentAPI(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
s := &Server{recorder: rec}
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
w := httptest.NewRecorder()
s.getRecent(w, req)
body, _ := io.ReadAll(w.Body)
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("response missing ok:true: %s", string(body))
}
if !strings.Contains(string(body), "/v1/responses") {
t.Fatalf("response missing /v1/responses: %s", string(body))
}
// newest first
idxResponses := strings.Index(string(body), "/v1/responses")
idxChat := strings.Index(string(body), "/v1/chat/completions")
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
t.Fatalf("entries not newest-first: %s", string(body))
}
}
+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)
}
}
+726 -294
View File
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/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. The proxy is backed by a temp SQLite store so credentials persist in the
// db during the test instead of a JSON file.
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, *store.Store) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/query/acepilot-h5/manager/code/getAuthCode":
writeJSON(w, http.StatusOK, map[string]any{"state": "OK"})
case "/api/query/acepilot-h5/manager/code/checkCode":
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
"result": true,
"ak": "BASE64AK", "sk": "BASE64SK", "license": "BASE64TOKEN",
}})
case "/api/acepilot/zhanlu/v1/login":
if r.Header.Get("plugin_type") != "zhanlu_ide" {
writeJSON(w, http.StatusBadRequest, map[string]any{"state": "ERROR", "errorMessage": "bad plugin_type"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "cmcc", "team": "ai",
}})
case "/user/api/v2/external/key/get-or-create":
writeJSON(w, http.StatusOK, map[string]any{"apiKey": "sk-test-456"})
case "/chat/completions":
if r.Header.Get("Authorization") != "Bearer sk-test-456" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"message": "bad auth"}})
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20,\"total_tokens\":30,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
case "/gateway/v1/model/info":
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{
{"model_name": "GLM-4.7"}, {"id": "MiniMaxAI/MiniMax-M2.5"},
}})
default:
http.NotFound(w, r)
}
}))
st, err := store.Open(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
cfg := config.Config{
ListenAddr: ":0",
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
TokenDecryptKey: "3jw7woww2rvhla6k",
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
return upstream, proxy, st
}
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
func TestPhoneLoginAndChat(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// 1. request phone code
resp, err := http.Post(proxy.URL+"/api/auth/code", "application/json", strings.NewReader(`{"telephone":"13800000000"}`))
if err != nil {
t.Fatal(err)
}
var codeResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&codeResp)
resp.Body.Close()
secret, _ := codeResp["secret"].(string)
// 2. login with phone code (server RSA-encrypts the telephone itself)
loginBody, _ := json.Marshal(map[string]string{"telephone": "13800000000", "code": "123456", "secret": secret})
resp, err = http.Post(proxy.URL+"/api/auth/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
t.Fatal(err)
}
var loginResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&loginResp)
resp.Body.Close()
if !okValue(loginResp) {
t.Fatalf("login failed: %v", loginResp)
}
// 3. credentials store should contain the provisioned api key
creds, err := st.LoadCredentials()
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "sk-test-456" {
t.Fatalf("apiKey = %q", creds.APIKey)
}
if creds.Email != "[email protected]" {
t.Fatalf("email = %q", creds.Email)
}
// 4. non-streaming chat completion
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var chatResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
t.Fatal(err)
}
choices, _ := chatResp["choices"].([]any)
if len(choices) != 1 {
t.Fatalf("chat choices = %v", chatResp)
}
msg, _ := choices[0].(map[string]any)["message"].(map[string]any)
if msg["content"] != "hello" {
t.Fatalf("chat content = %v", msg)
}
// 5. models endpoint should prefer gateway model info
resp, err = http.Get(proxy.URL + "/v1/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var modelsResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&modelsResp)
items, _ := modelsResp["data"].([]any)
if len(items) != 2 {
t.Fatalf("models = %v", modelsResp)
}
}
func TestStreamingChat(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
// Seed credentials directly with the api key into the store
creds := auth.Credentials{
AccessKey: "AK",
SecretKey: "SK",
Token: "TOKEN",
APIKey: "sk-test-456",
ModelBaseURL: upstream.URL,
Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
if !strings.Contains(body, "data: ") || !strings.Contains(body, "hello") || !strings.Contains(body, "[DONE]") {
t.Fatalf("stream body: %s", body)
}
}
// TestStats verifies both streaming and non-streaming chat paths record token
// usage into the store and that GET /api/stats aggregates them correctly.
func TestStats(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
// non-streaming chat
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// streaming chat
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// query stats
resp, err = http.Get(proxy.URL + "/api/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var statsResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&statsResp); err != nil {
t.Fatal(err)
}
if enabled, _ := statsResp["enabled"].(bool); !enabled {
t.Fatalf("stats not enabled: %v", statsResp)
}
s, _ := statsResp["stats"].(map[string]any)
if s == nil {
t.Fatalf("no stats object: %v", statsResp)
}
totals, _ := s["totals"].(map[string]any)
if totals == nil {
t.Fatalf("no totals: %v", s)
}
if got := totNum(totals["requests"]); got != 2 {
t.Fatalf("requests = %v, want 2", totals["requests"])
}
if got := totNum(totals["prompt_tokens"]); got != 20 {
t.Fatalf("prompt_tokens = %v, want 20", totals["prompt_tokens"])
}
if got := totNum(totals["completion_tokens"]); got != 40 {
t.Fatalf("completion_tokens = %v, want 40", totals["completion_tokens"])
}
if got := totNum(totals["total_tokens"]); got != 60 {
t.Fatalf("total_tokens = %v, want 60", totals["total_tokens"])
}
if got := totNum(totals["cached_tokens"]); got != 8 {
t.Fatalf("cached_tokens = %v, want 8", totals["cached_tokens"])
}
if rate, _ := totals["cache_rate"].(float64); rate < 0.39 || rate > 0.41 {
t.Fatalf("cache_rate = %v, want ~0.4", totals["cache_rate"])
}
perModel, _ := s["per_model"].([]any)
if len(perModel) != 1 {
t.Fatalf("per_model = %v, want 1 entry", perModel)
}
}
// 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
}
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
var _ = context.Background
const defaultTestPublicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+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)))
}
+609
View File
@@ -0,0 +1,609 @@
<!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}
.recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)}
.recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
.recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px}
.recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-card details{margin-top:6px}
.recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none}
.recent-card summary:hover{color:var(--accent)}
.recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;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="recent" 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="recent" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
</div>
<div class="panel">
<h2>最近 API 请求</h2>
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
<div id="recent-list"></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 === 'recent') activate('recent');
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();
})();
(function () {
var recentList = document.getElementById('recent-list');
if (!recentList) return;
var loaded = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function formatTime(ts) {
var d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
async function loadRecent() {
recentList.innerHTML = '<p class="muted">加载中…</p>';
try {
var res = await fetch('/api/recent');
var data = await res.json();
if (!data.ok || !data.requests || data.requests.length === 0) {
recentList.innerHTML = '<p class="empty">暂无数据</p>';
return;
}
var html = '';
for (var i = 0; i < data.requests.length; i++) {
var r = data.requests[i];
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
var headers = r.request_headers || {};
var headerStr = Object.keys(headers).map(function (k) {
return k + ': ' + headers[k];
}).join('\n');
html += '<div class="recent-card">' +
'<div class="recent-head">' +
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
'</div>' +
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
'</div>';
}
recentList.innerHTML = html;
} catch (e) {
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
}
}
var recentTab = document.querySelector('.tab[data-tab="recent"]');
if (recentTab) recentTab.addEventListener('click', function () {
if (!loaded) { loaded = true; loadRecent(); }
});
var refreshBtn = document.getElementById('recent-refresh');
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
// auto-load if the tab is active on page load
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
})();
</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>
+3 -1
View File
@@ -46,7 +46,9 @@ func (s Signer) BuildOpURL(path string, creds auth.Credentials, baseURL string,
if err != nil {
return "", err
}
timestamp := now().Format("2006-01-02T15:04:05Z")
// The plugin formats Beijing time (UTC+8) with a Z suffix via
// new Date(now.getTime()+8*3600*1000) and getUTC* accessors.
timestamp := now().Add(8 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
query := "AccessKey=" + creds.AccessKey +
"&SignatureMethod=HmacSHA1" +
"&SignatureNonce=" + nonce() +
+74
View File
@@ -0,0 +1,74 @@
package sign
import (
"encoding/hex"
"net/url"
"strings"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
const testPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
func TestBuildOpURLStructure(t *testing.T) {
pub, err := auth.ParsePublicKey(testPublicKeyPEM)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 5, 2, 30, 0, 0, time.UTC)
s := Signer{
PublicKey: pub,
Now: func() time.Time { return now },
Nonce: func() string { return "fixed-nonce-1234567890" },
}
creds := auth.Credentials{AccessKey: "AK123", SecretKey: "SK456", Token: "TOK789"}
u, err := s.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, "https://ecloud.10086.cn", "POST")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(u, "https://ecloud.10086.cn/api/acepilot/zhanlu/v1/login?") {
t.Fatalf("url = %s", u)
}
parsed, err := url.Parse(u)
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if q.Get("AccessKey") != "AK123" {
t.Errorf("AccessKey = %q", q.Get("AccessKey"))
}
if q.Get("SignatureMethod") != "HmacSHA1" {
t.Errorf("SignatureMethod = %q", q.Get("SignatureMethod"))
}
if q.Get("SignatureVersion") != "V2.0" {
t.Errorf("SignatureVersion = %q", q.Get("SignatureVersion"))
}
if q.Get("Version") != "2016-12-05" {
t.Errorf("Version = %q", q.Get("Version"))
}
// Beijing time (UTC+8) formatted with Z suffix, matching the plugin's yE9.
if q.Get("Timestamp") != "2026-08-05T10:30:00Z" {
t.Errorf("Timestamp = %q, want 2026-08-05T10:30:00Z (Beijing time)", q.Get("Timestamp"))
}
if q.Get("Signature") == "" {
t.Error("Signature is empty")
}
if _, err := hex.DecodeString(q.Get("Signature")); err != nil {
t.Errorf("Signature is not hex: %v", err)
}
authz := q.Get("authorization")
if authz == "" {
t.Error("authorization is empty")
}
}
func TestBuildOpURLMissingCreds(t *testing.T) {
s := Signer{}
if _, err := s.BuildOpURL("/x", auth.Credentials{}, "http://x", "POST"); err == nil {
t.Fatal("expected error for missing credentials")
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package stats defines the types and helpers for OpenAI-compatible token
// usage statistics recorded by the zhanlu proxy. The concrete SQLite-backed
// recorder lives in internal/store; this package is dependency-free so it can
// be referenced by both store and server without import cycles.
package stats
import "time"
// Record is a single chat-completion usage observation.
type Record struct {
Ts time.Time `json:"ts"`
Model string `json:"model"`
Stream bool `json:"stream"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
Status string `json:"status"` // "success" | "upstream_error"
LatencyMs int64 `json:"latency_ms"`
}
// Query filters the recorded stats. Zero-value time fields mean unbounded on
// that end; empty Model means all models. Limit caps the recent-records list
// (0 = default). Stream filters by streaming mode (nil = both).
type Query struct {
Since time.Time
Until time.Time
Model string
Limit int
Stream *bool
}
// Totals aggregates request counts and token sums over a filtered set.
type Totals struct {
Requests int `json:"requests"`
SuccessRequests int `json:"success_requests"`
ErrorRequests int `json:"error_requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
CachedTokens int64 `json:"cached_tokens"`
CacheRate float64 `json:"cache_rate"` // cached_tokens / prompt_tokens, 0..1
}
// ModelStat is a per-model aggregation row.
type ModelStat struct {
Model string `json:"model"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// DayStat is a per-day aggregation row (server-local time, YYYY-MM-DD).
type DayStat struct {
Day string `json:"day"`
Requests int `json:"requests"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
CachedTokens int64 `json:"cached_tokens"`
}
// Summary is the full result returned by a Recorder's Stats query.
type Summary struct {
Totals Totals `json:"totals"`
PerModel []ModelStat `json:"per_model"`
Daily []DayStat `json:"daily"`
Recent []Record `json:"recent"`
}
// Recorder persists and queries usage statistics. The concrete implementation
// lives in internal/store; the interface is declared here so server code can
// depend on the contract and tests can inject fakes.
type Recorder interface {
Record(r Record) error
Stats(q Query) (*Summary, error)
Reset() error
Close() error
}
+78
View File
@@ -0,0 +1,78 @@
package stats
import (
"encoding/json"
"time"
)
// Usage is the subset of the OpenAI chat-completion usage object the recorder
// persists. Numbers arrive from JSON unmarshal as float64.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
CachedTokens int `json:"cached_tokens"`
// PromptTokensDetails.CachedTokens is emitted by providers that support
// prompt caching (OpenAI/DeepSeek/Zhipu litellm gateways). Some upstreams
// put cached_tokens at the top level instead.
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
// CompletionTokensDetails.ReasoningTokens is emitted by reasoning models;
// some upstreams put reasoning_tokens at top level instead.
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
}
// ExtractUsage decodes a raw usage value (as produced by encoding/json into an
// any) into token counts. It accepts both full usage maps and raw JSON bytes.
// Missing fields default to 0; a nil v yields zero usage.
func ExtractUsage(v any) Usage {
var u Usage
if v == nil {
return u
}
switch t := v.(type) {
case []byte:
_ = json.Unmarshal(t, &u)
case json.RawMessage:
_ = json.Unmarshal(t, &u)
case map[string]any:
// Re-marshal + unmarshal is the simplest robust path for nested
// *_tokens_details; usage payloads are tiny.
if b, err := json.Marshal(t); err == nil {
_ = json.Unmarshal(b, &u)
}
}
if u.ReasoningTokens == 0 {
u.ReasoningTokens = u.CompletionTokensDetails.ReasoningTokens
}
if u.CachedTokens == 0 {
u.CachedTokens = u.PromptTokensDetails.CachedTokens
}
return u
}
// RecordFromUsage builds a Record from a captured usage value plus context.
func RecordFromUsage(model string, stream bool, usage any, status string, start time.Time) Record {
u := ExtractUsage(usage)
if u.TotalTokens == 0 && (u.PromptTokens != 0 || u.CompletionTokens != 0) {
u.TotalTokens = u.PromptTokens + u.CompletionTokens
}
return Record{
Ts: time.Now(),
Model: model,
Stream: stream,
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.TotalTokens,
ReasoningTokens: u.ReasoningTokens,
CachedTokens: u.CachedTokens,
Status: status,
LatencyMs: time.Since(start).Milliseconds(),
}
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
)
// LoadCredentials reads the persisted credentials. The credentials row always
// exists after Open; an empty row (nothing saved yet) yields a zero-value
// Credentials with a nil error — callers check Validate()/HasAPIKey().
func (s *Store) LoadCredentials() (auth.Credentials, error) {
var c auth.Credentials
var savedAt int64
err := s.db.QueryRow(`SELECT access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at FROM credentials WHERE id = 1`).
Scan(&c.AccessKey, &c.SecretKey, &c.Token, &c.APIKey, &c.ModelBaseURL, &c.Email, &c.Organization, &c.Team, &c.BaseURL, &savedAt)
if err != nil {
return auth.Credentials{}, fmt.Errorf("load credentials: %w", err)
}
c.SavedAt = time.Unix(savedAt, 0).Local()
return c, nil
}
// SaveCredentials upserts the credentials into the single row. It only writes
// when the credentials validate or already carry an API key, so partial /
// env-only creds are not persisted.
func (s *Store) SaveCredentials(c auth.Credentials) error {
if c.Validate() != nil && !c.HasAPIKey() {
return fmt.Errorf("save credentials: %w", c.Validate())
}
c.SavedAt = time.Now()
_, err := s.db.Exec(`INSERT INTO credentials (id, access_key, secret_key, token, api_key, model_base_url, email, organization, team, base_url, saved_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
access_key=excluded.access_key, secret_key=excluded.secret_key, token=excluded.token,
api_key=excluded.api_key, model_base_url=excluded.model_base_url, email=excluded.email,
organization=excluded.organization, team=excluded.team, base_url=excluded.base_url,
saved_at=excluded.saved_at`,
c.AccessKey, c.SecretKey, c.Token, c.APIKey, c.ModelBaseURL, c.Email, c.Organization, c.Team, c.BaseURL, c.SavedAt.Unix())
if err != nil {
return fmt.Errorf("save credentials: %w", err)
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package store
import (
"fmt"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
// Compile-time guard: *Store satisfies stats.Recorder.
var _ stats.Recorder = (*Store)(nil)
const defaultRecentLimit = 200
// Record appends a single usage observation.
func (s *Store) Record(rec stats.Record) error {
if rec.Status == "" {
rec.Status = "success"
}
stream := 0
if rec.Stream {
stream = 1
}
_, err := s.db.Exec(`INSERT INTO requests (ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms) VALUES (?,?,?,?,?,?,?,?,?,?)`,
rec.Ts.Unix(), rec.Model, stream, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens, rec.ReasoningTokens, rec.CachedTokens, rec.Status, rec.LatencyMs)
return err
}
// Stats computes the aggregate summary for the given query.
func (s *Store) Stats(q stats.Query) (*stats.Summary, error) {
q = normalizeQuery(q)
where, args := whereClause(q)
sum := &stats.Summary{}
if err := s.scanTotals(sum, where, args); err != nil {
return nil, err
}
if sum.Totals.PromptTokens > 0 {
sum.Totals.CacheRate = float64(sum.Totals.CachedTokens) / float64(sum.Totals.PromptTokens)
}
if err := s.scanPerModel(sum, where, args); err != nil {
return nil, err
}
if err := s.scanDaily(sum, where, args); err != nil {
return nil, err
}
if err := s.scanRecent(sum, q, where, args); err != nil {
return nil, err
}
return sum, nil
}
// Reset deletes all recorded usage statistics (the credentials row is kept).
func (s *Store) Reset() error {
_, err := s.db.Exec(`DELETE FROM requests`)
return err
}
func normalizeQuery(q stats.Query) stats.Query {
if q.Limit <= 0 {
q.Limit = defaultRecentLimit
}
if q.Limit > 5000 {
q.Limit = 5000
}
return q
}
func whereClause(q stats.Query) (string, []any) {
var conds []string
var args []any
if !q.Since.IsZero() {
conds = append(conds, "ts >= ?")
args = append(args, q.Since.Unix())
}
if !q.Until.IsZero() {
conds = append(conds, "ts <= ?")
args = append(args, q.Until.Unix())
}
if q.Model != "" {
conds = append(conds, "model = ?")
args = append(args, q.Model)
}
if q.Stream != nil {
conds = append(conds, "stream = ?")
args = append(args, boolToInt(*q.Stream))
}
if len(conds) == 0 {
return "", args
}
out := ""
for i, p := range conds {
if i > 0 {
out += " AND "
}
out += p
}
return " WHERE " + out, args
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (s *Store) scanTotals(sum *stats.Summary, where string, args []any) error {
q := `SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN status!='success' THEN 1 ELSE 0 END),0),
COALESCE(SUM(prompt_tokens),0),
COALESCE(SUM(completion_tokens),0),
COALESCE(SUM(total_tokens),0),
COALESCE(SUM(reasoning_tokens),0),
COALESCE(SUM(cached_tokens),0) FROM requests` + where
row := s.db.QueryRow(q, args...)
var success, failures int64
err := row.Scan(&sum.Totals.Requests, &success, &failures,
&sum.Totals.PromptTokens, &sum.Totals.CompletionTokens,
&sum.Totals.TotalTokens, &sum.Totals.ReasoningTokens, &sum.Totals.CachedTokens)
if err != nil {
return fmt.Errorf("scan totals: %w", err)
}
sum.Totals.SuccessRequests = int(success)
sum.Totals.ErrorRequests = int(failures)
return nil
}
func (s *Store) scanPerModel(sum *stats.Summary, where string, args []any) error {
q := `SELECT model, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY model ORDER BY SUM(total_tokens) DESC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan per-model: %w", err)
}
defer rows.Close()
for rows.Next() {
var m stats.ModelStat
if err := rows.Scan(&m.Model, &m.Requests, &m.PromptTokens, &m.CompletionTokens, &m.TotalTokens, &m.CachedTokens); err != nil {
return err
}
sum.PerModel = append(sum.PerModel, m)
}
return rows.Err()
}
func (s *Store) scanDaily(sum *stats.Summary, where string, args []any) error {
q := `SELECT date(ts,'unixepoch','localtime') AS day, COUNT(*), COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cached_tokens),0) FROM requests` + where + " GROUP BY day ORDER BY day ASC"
rows, err := s.db.Query(q, args...)
if err != nil {
return fmt.Errorf("scan daily: %w", err)
}
defer rows.Close()
for rows.Next() {
var d stats.DayStat
if err := rows.Scan(&d.Day, &d.Requests, &d.PromptTokens, &d.CompletionTokens, &d.TotalTokens, &d.CachedTokens); err != nil {
return err
}
sum.Daily = append(sum.Daily, d)
}
return rows.Err()
}
func (s *Store) scanRecent(sum *stats.Summary, q stats.Query, where string, args []any) error {
limit := q.Limit
if limit <= 0 {
limit = defaultRecentLimit
}
query := `SELECT ts, model, stream, prompt_tokens, completion_tokens, total_tokens, reasoning_tokens, cached_tokens, status, latency_ms FROM requests` + where + " ORDER BY id DESC LIMIT ?"
rows, err := s.db.Query(query, append(args, limit)...)
if err != nil {
return fmt.Errorf("scan recent: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec stats.Record
var ts int64
var stream int
if err := rows.Scan(&ts, &rec.Model, &stream, &rec.PromptTokens, &rec.CompletionTokens, &rec.TotalTokens, &rec.ReasoningTokens, &rec.CachedTokens, &rec.Status, &rec.LatencyMs); err != nil {
return err
}
rec.Ts = time.Unix(ts, 0).Local()
rec.Stream = stream == 1
sum.Recent = append(sum.Recent, rec)
}
return rows.Err()
}
+89
View File
@@ -0,0 +1,89 @@
// Package store owns the single embedded SQLite database backing the zhanlu
// proxy: token-usage records (the requests table) and the persisted login
// credentials (the credentials table, single-tenant single row). It implements
// stats.Recorder for usage tracking and exposes Load/Save for credentials.
package store
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// Store is the single owner of the proxy's SQLite database handle.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the database at path and ensures both tables exist.
// SQLite is opened with WAL journaling and a busy timeout so concurrent reads
// (stats queries) and writes (request records, credential saves) do not
// collide.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("open db %q: %w", path, err)
}
if err := ensureSchema(db); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close releases the database handle.
func (s *Store) Close() error {
return s.db.Close()
}
func ensureSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
model TEXT NOT NULL,
stream INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success',
latency_ms INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
access_key TEXT NOT NULL DEFAULT '',
secret_key TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
model_base_url TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
organization TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '',
base_url TEXT NOT NULL DEFAULT '',
saved_at INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts)`,
`CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model)`,
// Ensure the single credentials row exists so UPSERTs and SELECTs always
// have a target.
`INSERT INTO credentials (id) VALUES (1) ON CONFLICT(id) DO NOTHING`,
}
for _, q := range stmts {
if _, err := db.Exec(q); err != nil {
return fmt.Errorf("schema: %w", err)
}
}
// Add cached_tokens to databases created before this column existed. SQLite
// returns "duplicate column name" when it already exists; that is expected
// and ignored.
if _, err := db.Exec(`ALTER TABLE requests ADD COLUMN cached_tokens INTEGER NOT NULL DEFAULT 0`); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migrate cached_tokens: %w", err)
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"path/filepath"
"testing"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestCredentialsRoundTrip(t *testing.T) {
st := newTestStore(t)
in := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOK",
APIKey: "sk-1", ModelBaseURL: "https://up.example", Email: "[email protected]",
Organization: "org", Team: "team",
}
if err := st.SaveCredentials(in); err != nil {
t.Fatalf("save: %v", err)
}
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load: %v", err)
}
if got.APIKey != "sk-1" || got.Email != "[email protected]" || got.Organization != "org" {
t.Fatalf("round-trip mismatch: %+v", got)
}
if got.SavedAt.IsZero() {
t.Fatalf("saved_at not set")
}
}
func TestEmptyLoadReturnsZero(t *testing.T) {
st := newTestStore(t)
got, err := st.LoadCredentials()
if err != nil {
t.Fatalf("load on empty store: %v", err)
}
if got.HasAPIKey() {
t.Fatalf("expected no api key on empty store, got %+v", got)
}
}
func TestRecordStatsReset(t *testing.T) {
st := newTestStore(t)
now := time.Now()
recs := []stats.Record{
{Ts: now, Model: "glm-4.7", Stream: false, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4, Status: "success", LatencyMs: 5},
{Ts: now, Model: "glm-4.7", Stream: true, PromptTokens: 5, CompletionTokens: 5, TotalTokens: 10, Status: "success", LatencyMs: 8},
{Ts: now, Model: "minimax", Stream: false, PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2, Status: "upstream_error", LatencyMs: 3},
}
for _, r := range recs {
if err := st.Record(r); err != nil {
t.Fatalf("record: %v", err)
}
}
sum, err := st.Stats(stats.Query{})
if err != nil {
t.Fatalf("stats: %v", err)
}
if sum.Totals.Requests != 3 {
t.Fatalf("requests = %d, want 3", sum.Totals.Requests)
}
if sum.Totals.SuccessRequests != 2 || sum.Totals.ErrorRequests != 1 {
t.Fatalf("success/error = %d/%d, want 2/1", sum.Totals.SuccessRequests, sum.Totals.ErrorRequests)
}
if sum.Totals.TotalTokens != 42 {
t.Fatalf("total tokens = %d, want 42", sum.Totals.TotalTokens)
}
if sum.Totals.CachedTokens != 4 {
t.Fatalf("cached tokens = %d, want 4", sum.Totals.CachedTokens)
}
// prompt total = 10+5+1 = 16, cached = 4 => 0.25
if sum.Totals.CacheRate < 0.24 || sum.Totals.CacheRate > 0.26 {
t.Fatalf("cache rate = %v, want ~0.25", sum.Totals.CacheRate)
}
if len(sum.PerModel) != 2 {
t.Fatalf("per-model entries = %d, want 2", len(sum.PerModel))
}
// glm-4.7 should lead on total tokens (40 vs 2)
if sum.PerModel[0].Model != "glm-4.7" || sum.PerModel[0].TotalTokens != 40 || sum.PerModel[0].CachedTokens != 4 {
t.Fatalf("top model = %+v, want glm-4.7/40/4 cached", sum.PerModel[0])
}
if len(sum.Recent) != 3 {
t.Fatalf("recent entries = %d, want 3", len(sum.Recent))
}
// most recent first (id desc) => minimax record
if sum.Recent[0].Model != "minimax" {
t.Fatalf("most recent = %+v, want minimax", sum.Recent[0])
}
// model filter
sumF, _ := st.Stats(stats.Query{Model: "minimax"})
if sumF.Totals.Requests != 1 || sumF.Totals.TotalTokens != 2 {
t.Fatalf("filtered stats = %+v, want 1/2", sumF.Totals)
}
if err := st.Reset(); err != nil {
t.Fatalf("reset: %v", err)
}
sum2, _ := st.Stats(stats.Query{})
if sum2.Totals.Requests != 0 {
t.Fatalf("after reset requests = %d, want 0", sum2.Totals.Requests)
}
// credentials must survive a stats reset
creds, _ := st.LoadCredentials()
_ = creds
}
+36
View File
@@ -0,0 +1,36 @@
// Package util holds small shared helpers used across internal packages to
// avoid divergent same-named copies.
package util
import (
"crypto/rand"
"fmt"
"strings"
"time"
)
// 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 ""
}
// RandomRequestID returns a random v4 UUID string, matching the format the
// Zhanlu plugin sends in the `request` header of every gateway call
// (crypto.randomUUID()). Extracted so the login and phone-code paths share
// one implementation.
func RandomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
+215 -35
View File
@@ -4,67 +4,247 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"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 {
BaseURL string
Path string
Creds auth.Credentials
Signer sign.Signer
HTTPClient *http.Client
LoginBaseURL string
ModelBaseURL string
ChatPath string
PluginVersion string
SM2PrivateKey string
Signer sign.Signer
HTTPClient *http.Client
}
func NewClient(baseURL, path string, creds auth.Credentials, signer sign.Signer, timeout time.Duration) *Client {
func NewClient(loginBaseURL, modelBaseURL, chatPath, pluginVersion, sm2PrivateKey string, signer sign.Signer, timeout time.Duration) *Client {
// The Zhanlu gateway rejects HTTP/2 ALPN negotiation (connection EOF on
// handshake), so force HTTP/1.1 for all upstream requests.
transport := &http.Transport{ForceAttemptHTTP2: false}
return &Client{
BaseURL: baseURL,
Path: path,
Creds: creds,
Signer: signer,
HTTPClient: &http.Client{
Timeout: timeout,
},
LoginBaseURL: loginBaseURL,
ModelBaseURL: modelBaseURL,
ChatPath: chatPath,
PluginVersion: pluginVersion,
SM2PrivateKey: sm2PrivateKey,
Signer: signer,
HTTPClient: &http.Client{Timeout: timeout, Transport: transport},
}
}
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) {
baseURL := c.BaseURL
if c.Creds.BaseURL != "" {
baseURL = c.Creds.BaseURL
}
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
// LoginProfile validates AK/SK/token against the Zhanlu gateway
// (POST /api/acepilot/zhanlu/v1/login) and returns the decrypted user profile.
func (c *Client) LoginProfile(ctx context.Context, creds auth.Credentials) (auth.Profile, error) {
signedURL, err := c.Signer.BuildOpURL("/api/acepilot/zhanlu/v1/login", creds, c.LoginBaseURL, http.MethodPost)
if err != nil {
return nil, err
return auth.Profile{}, err
}
encryptedBody, err := auth.EncryptCredential(string(body), c.Creds.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, strings.NewReader(`{}`))
if err != nil {
return nil, err
return auth.Profile{}, err
}
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody))
c.setPluginHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return auth.Profile{}, err
}
defer resp.Body.Close()
var out struct {
State string `json:"state"`
Body struct {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
Name string `json:"name"`
Telephone string `json:"telephone"`
} `json:"body"`
}
if err := decodeJSON(resp, &out); err != nil {
return auth.Profile{}, err
}
if out.State != "OK" {
return auth.Profile{}, fmt.Errorf("zhanlu login failed: state=%s", out.State)
}
return auth.Profile{
Email: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Email), creds.Token),
Organization: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Organization), creds.Token),
Team: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Team), creds.Token),
UserName: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Name), creds.Token),
Telephone: auth.DecryptCredentialOrRaw(strings.TrimSpace(out.Body.Telephone), creds.Token),
}, nil
}
// ProvisionAPIKey requests a Zhanlu gateway API key
// (POST {modelBaseUrl}/user/api/v2/external/key/get-or-create) using the
// SM2-signed X-Auth-* headers, then returns the apiKey. Empty organization and
// team default to the plugin's "未配置" placeholder.
func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team string) (string, error) {
if strings.TrimSpace(c.SM2PrivateKey) == "" {
return "", fmt.Errorf("ZHANLU_APIKEY_AUTH_SM2_PRIVATE_KEY is required to provision an API key")
}
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := 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 {
Email string `json:"email"`
Organization string `json:"organization"`
Team string `json:"team"`
}{email, org, tm})
if err != nil {
return "", err
}
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/user/api/v2/external/key/get-or-create"
timestamp := fmt.Sprintf("%d", time.Now().Unix())
nonce := randomAlnum(32)
signature, err := auth.SignSM2Authorization(c.SM2PrivateKey, timestamp+":"+nonce+":"+string(body))
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Signature", signature)
req.Header.Set("X-Auth-Timestamp", timestamp)
req.Header.Set("X-Auth-Nonce", nonce)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("zhanlu api key provisioning returned %d: %s", resp.StatusCode, string(b))
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
apiKey := findString(out, "apiKey", "key", "api_key")
if apiKey == "" {
return "", fmt.Errorf("zhanlu api key provisioning response missing apiKey")
}
return apiKey, nil
}
// ChatCompletions posts an OpenAI-compatible body to the Zhanlu gateway chat
// endpoint authenticated with the provisioned API key.
func (c *Client) ChatCompletions(ctx context.Context, apiKey string, body []byte) (*http.Response, error) {
baseURL := c.ModelBaseURL
path := c.ChatPath
if path == "" {
path = "/chat/completions"
}
endpoint := strings.TrimRight(baseURL, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream, application/json")
req.Header.Set("plugin_type", "vscode")
req.Header.Set("plugin_version", "2.8.0")
req.Header.Set("service_type", "code")
req.Header.Set("request", randomRequestID())
req.Header.Set("Authorization", "Bearer "+apiKey)
return c.HTTPClient.Do(req)
}
func randomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
// Models lists model ids exposed by the gateway model-info endpoint
// (GET {modelBaseUrl}/gateway/v1/model/info) with the provisioned API key.
func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
endpoint := strings.TrimRight(c.ModelBaseURL, "/") + "/gateway/v1/model/info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("zhanlu model info returned %d: %s", resp.StatusCode, string(b))
}
var out struct {
Data []struct {
ModelName string `json:"model_name"`
ID string `json:"id"`
ModelInfo struct {
ID string `json:"id"`
} `json:"model_info"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
seen := map[string]bool{}
models := make([]string, 0, len(out.Data))
for _, m := range out.Data {
id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
models = append(models, id)
}
return models, nil
}
func (c *Client) setPluginHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", c.PluginVersion)
req.Header.Set("request", util.RandomRequestID())
}
func decodeJSON(resp *http.Response, out any) error {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("zhanlu upstream returned %d: %s", resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func findString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
// The plugin searches the root and the data/body/result/payload containers.
for _, container := range []string{"data", "body", "result", "payload"} {
if v, ok := m[container].(map[string]any); ok {
if s := findString(v, keys...); s != "" {
return s
}
}
}
return ""
}
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
func randomAlnum(n int) string {
b := make([]byte, n)
rand.Read(b)
for i := range b {
b[i] = alnum[int(b[i])%len(alnum)]
}
return string(b)
}
+143
View File
@@ -0,0 +1,143 @@
package zhanlu
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
)
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
func TestClientFlow(t *testing.T) {
var gotLoginHeaders, gotProvisionHeaders http.Header
var gotChatAuth, gotModelsAuth string
mux := http.NewServeMux()
mux.HandleFunc("/api/acepilot/zhanlu/v1/login", func(w http.ResponseWriter, r *http.Request) {
gotLoginHeaders = r.Header
// echo a profile whose fields are plaintext (DecryptCredentialOrRaw fallback)
writeJSON(t, w, map[string]any{"state": "OK", "body": map[string]any{
"email": "[email protected]", "organization": "cmcc", "team": "ai", "name": "Dev", "telephone": "13800000000",
}})
})
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
gotProvisionHeaders = r.Header
if gotProvisionHeaders.Get("X-Auth-Signature") == "" || gotProvisionHeaders.Get("X-Auth-Timestamp") == "" || gotProvisionHeaders.Get("X-Auth-Nonce") == "" {
t.Errorf("provision request missing X-Auth-* headers: %v", gotProvisionHeaders)
}
if gotProvisionHeaders.Get("X-Auth-Nonce") == "" || len(gotProvisionHeaders.Get("X-Auth-Nonce")) != 32 {
t.Errorf("X-Auth-Nonce should be 32 chars")
}
writeJSON(t, w, map[string]any{"apiKey": "sk-zhanlu-test-123"})
})
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
gotChatAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
})
mux.HandleFunc("/gateway/v1/model/info", func(w http.ResponseWriter, r *http.Request) {
gotModelsAuth = r.Header.Get("Authorization")
writeJSON(t, w, map[string]any{"data": []map[string]any{
{"model_name": "GLM-4.7"},
{"id": "MiniMaxAI/MiniMax-M2.5"},
{"model_info": map[string]any{"id": "qwen-max"}},
}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
pub, err := auth.ParsePublicKey(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`)
if err != nil {
t.Fatal(err)
}
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{PublicKey: pub}, 0)
creds := auth.Credentials{AccessKey: "AK", SecretKey: "SK", Token: "TOKEN"}
profile, err := c.LoginProfile(context.Background(), creds)
if err != nil {
t.Fatalf("LoginProfile: %v", err)
}
if profile.Email != "[email protected]" {
t.Fatalf("profile email = %q", profile.Email)
}
if gotLoginHeaders.Get("plugin_type") != "zhanlu_ide" {
t.Errorf("plugin_type header = %q", gotLoginHeaders.Get("plugin_type"))
}
if gotLoginHeaders.Get("plugin_version") != "1.4.2" {
t.Errorf("plugin_version header = %q", gotLoginHeaders.Get("plugin_version"))
}
apiKey, err := c.ProvisionAPIKey(context.Background(), profile.Email, profile.Organization, profile.Team)
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "sk-zhanlu-test-123" {
t.Fatalf("apiKey = %q", apiKey)
}
resp, err := c.ChatCompletions(context.Background(), apiKey, []byte(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}]}`))
if err != nil {
t.Fatalf("ChatCompletions: %v", err)
}
defer resp.Body.Close()
if gotChatAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("chat Authorization = %q", gotChatAuth)
}
models, err := c.Models(context.Background(), apiKey)
if err != nil {
t.Fatalf("Models: %v", err)
}
if len(models) != 3 || models[0] != "GLM-4.7" || models[1] != "MiniMaxAI/MiniMax-M2.5" || models[2] != "qwen-max" {
t.Fatalf("models = %v", models)
}
if gotModelsAuth != "Bearer sk-zhanlu-test-123" {
t.Errorf("models Authorization = %q", gotModelsAuth)
}
}
func TestProvisionAPIKeyDefaultsPlaceholders(t *testing.T) {
var body string
mux := http.NewServeMux()
mux.HandleFunc("/user/api/v2/external/key/get-or-create", func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
body = strings.TrimSpace(string(buf[:n]))
writeJSON(t, w, map[string]any{"data": map[string]any{"key": "k2"}})
})
ts := httptest.NewServer(mux)
defer ts.Close()
c := NewClient(ts.URL, ts.URL, "/chat/completions", "1.4.2", testSM2Key, sign.Signer{}, 0)
apiKey, err := c.ProvisionAPIKey(context.Background(), "[email protected]", "", "")
if err != nil {
t.Fatalf("ProvisionAPIKey: %v", err)
}
if apiKey != "k2" {
t.Fatalf("apiKey = %q", apiKey)
}
var parsed map[string]string
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatal(err)
}
if parsed["organization"] != "未配置" || parsed["team"] != "未配置" {
t.Fatalf("placeholders not applied: %v", parsed)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
+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