Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecdf0c44d1 | ||
|
|
22d78c4576 | ||
|
|
f988c47fec | ||
|
|
f51ec09a09 | ||
|
|
b9a3f8d5cd | ||
|
|
a0a2049440 | ||
|
|
9aba511abe | ||
|
|
2517b4f730 | ||
|
|
c8938cb514 |
@@ -14,3 +14,4 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
source/
|
source/
|
||||||
output/
|
output/
|
||||||
|
error.md
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
当前实现包含:
|
当前实现包含:
|
||||||
|
|
||||||
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 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`)。
|
- 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 认证。
|
- 湛卢登录签名逻辑: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`)。
|
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
|
||||||
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
||||||
- OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
|
- OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
|
||||||
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
|
- 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`)。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -168,6 +169,30 @@ curl -N http://127.0.0.1:8080/v1/chat/completions `
|
|||||||
|
|
||||||
请求中的 `tools`、`tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls` 和 `role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。
|
请求中的 `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`:
|
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -234,6 +259,22 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
|
|
||||||
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
|
设置 `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,与聊天接口一致。
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|
||||||
- `zhanlu.db` 数据库包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。
|
- `zhanlu.db` 数据库包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。
|
||||||
|
|||||||
+13
-14
@@ -5,9 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
|
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
|
||||||
@@ -39,9 +42,8 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
b := make([]byte, 1024)
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
n, _ := resp.Body.Read(b)
|
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var exchange ExchangeResponse
|
var exchange ExchangeResponse
|
||||||
@@ -49,11 +51,17 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
|
|||||||
return Profile{}, err
|
return Profile{}, err
|
||||||
}
|
}
|
||||||
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
|
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
|
||||||
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
|
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
||||||
}
|
}
|
||||||
if exchange.State != "" && exchange.State != "OK" {
|
if exchange.State != "" && exchange.State != "OK" {
|
||||||
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
|
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +103,3 @@ type ExchangeResponse struct {
|
|||||||
Result map[string]any `json:"result"`
|
Result map[string]any `json:"result"`
|
||||||
Data map[string]any `json:"data"`
|
Data map[string]any `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
|
||||||
for _, v := range values {
|
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown error"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -25,7 +26,7 @@ type Config struct {
|
|||||||
LoginPassword string
|
LoginPassword string
|
||||||
PluginVersion string
|
PluginVersion string
|
||||||
UpstreamTimeout time.Duration
|
UpstreamTimeout time.Duration
|
||||||
StreamIdleTimout time.Duration
|
StreamIdleTimeout time.Duration
|
||||||
Debug bool
|
Debug bool
|
||||||
Credentials auth.Credentials
|
Credentials auth.Credentials
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,7 @@ type Config struct {
|
|||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
|
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
|
||||||
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
|
MobileLoginBaseURL: util.FirstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
|
||||||
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
|
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
|
||||||
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
|
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
|
||||||
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
|
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
|
||||||
@@ -48,7 +49,7 @@ func Load() (Config, error) {
|
|||||||
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
|
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
|
||||||
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
|
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
|
||||||
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
|
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
|
||||||
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
|
StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
|
||||||
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
|
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
|
||||||
}
|
}
|
||||||
cfg.Credentials = auth.Credentials{
|
cfg.Credentials = auth.Credentials{
|
||||||
@@ -81,15 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
|
||||||
for _, v := range values {
|
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
||||||
-----END PUBLIC KEY-----`
|
-----END PUBLIC KEY-----`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
-2
@@ -1,6 +1,9 @@
|
|||||||
package openai
|
package openai
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type ChatCompletionRequest struct {
|
type ChatCompletionRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -30,7 +33,7 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
|
|||||||
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"model": r.Model,
|
"model": r.Model,
|
||||||
"messages": r.Messages,
|
"messages": normalizeMessages(r.Messages, extractToolNames(r.Extra)),
|
||||||
"stream": r.Stream,
|
"stream": r.Stream,
|
||||||
"stream_options": map[string]any{"include_usage": true},
|
"stream_options": map[string]any{"include_usage": true},
|
||||||
}
|
}
|
||||||
@@ -40,9 +43,195 @@ func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
|||||||
m[k] = anyValue
|
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)
|
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 {
|
type ErrorResponse struct {
|
||||||
Error ErrorBody `json:"error"`
|
Error ErrorBody `json:"error"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+294
-550
@@ -9,11 +9,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,10 +33,11 @@ type Server struct {
|
|||||||
loginSession string
|
loginSession string
|
||||||
st *store.Store
|
st *store.Store
|
||||||
statsEnabled bool
|
statsEnabled bool
|
||||||
|
recorder *RecentRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, st *store.Store) http.Handler {
|
func New(cfg config.Config, st *store.Store) http.Handler {
|
||||||
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
|
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()}
|
||||||
if cfg.LoginPassword != "" {
|
if cfg.LoginPassword != "" {
|
||||||
s.loginSession = randomSessionToken()
|
s.loginSession = randomSessionToken()
|
||||||
}
|
}
|
||||||
@@ -61,8 +63,12 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||||
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
||||||
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent))
|
||||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
|
||||||
|
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
||||||
|
s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models)))
|
||||||
|
s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
|
||||||
|
s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -134,6 +140,17 @@ func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isTLSRequest reports whether the request arrived over TLS, either directly
|
||||||
|
// (r.TLS != nil) or behind a reverse proxy that set X-Forwarded-Proto: https.
|
||||||
|
// The session cookie is only marked Secure over TLS so it still works on the
|
||||||
|
// default http://127.0.0.1 loopback deployment.
|
||||||
|
func isTLSRequest(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -147,13 +164,13 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.LoginPassword != "" {
|
if s.cfg.LoginPassword != "" {
|
||||||
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
|
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, Secure: isTLSRequest(r), SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: isTLSRequest(r), SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +297,7 @@ func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out.State != "OK" {
|
if out.State != "OK" {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": util.FirstNonEmpty(out.ErrorMessage, "验证码发送失败")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
|
||||||
@@ -320,7 +337,7 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !out.Body.Result {
|
if !out.Body.Result {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": util.FirstNonEmpty(out.ErrorMessage, "验证码校验失败")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
creds, err := decryptPhoneCredentials(out.Body, secret)
|
creds, err := decryptPhoneCredentials(out.Body, secret)
|
||||||
@@ -425,7 +442,7 @@ func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *p
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("plugin_type", "zhanlu_ide")
|
req.Header.Set("plugin_type", "zhanlu_ide")
|
||||||
req.Header.Set("plugin_version", s.cfg.PluginVersion)
|
req.Header.Set("plugin_version", s.cfg.PluginVersion)
|
||||||
req.Header.Set("request", randomRequestID())
|
req.Header.Set("request", util.RandomRequestID())
|
||||||
resp, err := s.upstreamHTTPClient().Do(req)
|
resp, err := s.upstreamHTTPClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -491,7 +508,7 @@ func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
|
|||||||
"path": s.cfg.DBPath,
|
"path": s.cfg.DBPath,
|
||||||
"access_key": mask(c.AccessKey),
|
"access_key": mask(c.AccessKey),
|
||||||
"has_api_key": c.APIKey != "",
|
"has_api_key": c.APIKey != "",
|
||||||
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
|
"model_base": util.FirstNonEmpty(c.ModelBaseURL, c.BaseURL),
|
||||||
"email": c.Email,
|
"email": c.Email,
|
||||||
"saved_at": c.SavedAt,
|
"saved_at": c.SavedAt,
|
||||||
})
|
})
|
||||||
@@ -531,8 +548,8 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
|
endpoint := util.FirstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
|
||||||
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
|
decryptKey := util.FirstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
|
||||||
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey)
|
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
@@ -593,6 +610,159 @@ func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelTestTimeout caps how long a single model probe may wait for the first
|
||||||
|
// token. The full upstream timeout (default 300s) is far too long for a probe.
|
||||||
|
const modelTestTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// getModels returns the live model list advertised by the upstream gateway
|
||||||
|
// model-info endpoint, fetched on every request so the admin console reflects
|
||||||
|
// newly published models without a restart. It provisions an API key on demand
|
||||||
|
// if the stored credentials lack one, mirroring the chat path.
|
||||||
|
func (s *Server) getModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
creds, err := s.currentCredentials()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "凭据未配置,请先在凭据登录页登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models, err := client.Models(r.Context(), creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
// testModel sends a minimal streaming chat completion to the upstream gateway
|
||||||
|
// for the requested model and measures the time to first token (TTFT) and the
|
||||||
|
// total probe duration, so the admin console can report availability and
|
||||||
|
// latency. The stream is closed as soon as the first content chunk arrives to
|
||||||
|
// avoid consuming tokens beyond what the probe needs. Probe requests are not
|
||||||
|
// recorded in token statistics.
|
||||||
|
func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(in.Model)
|
||||||
|
if model == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "model 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds, err := s.currentCredentials()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": "凭据未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||||
|
"stream": true,
|
||||||
|
"stream_options": map[string]any{"include_usage": true},
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), modelTestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.ChatCompletions(ctx, creds.APIKey, body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error(), "total_ms": time.Since(start).Milliseconds()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, string(b)), "total_ms": time.Since(start).Milliseconds()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the SSE stream until the first content chunk arrives (or an error),
|
||||||
|
// then close the connection so the probe consumes at most one token.
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
var ttft int64
|
||||||
|
gotStream := false
|
||||||
|
available := false
|
||||||
|
errMsg := ""
|
||||||
|
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"`
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(payload), &evt) == nil {
|
||||||
|
if evt.State == "ERROR" {
|
||||||
|
errMsg = util.FirstNonEmpty(evt.ErrorMessage, "上游返回错误")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !gotStream {
|
||||||
|
gotStream = true
|
||||||
|
ttft = time.Since(start).Milliseconds()
|
||||||
|
}
|
||||||
|
if len(evt.Choices) > 0 && evt.Choices[0].Delta.Content != "" {
|
||||||
|
available = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !gotStream && errMsg == "" {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotStream && errMsg == "" {
|
||||||
|
available = true
|
||||||
|
}
|
||||||
|
result := map[string]any{"ok": true, "model": model, "available": available, "total_ms": time.Since(start).Milliseconds()}
|
||||||
|
if gotStream {
|
||||||
|
result["ttft_ms"] = ttft
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
result["error"] = util.FirstNonEmpty(errMsg, "未收到响应内容")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func parseLimit(s string) int {
|
func parseLimit(s string) int {
|
||||||
n := 0
|
n := 0
|
||||||
for _, c := range s {
|
for _, c := range s {
|
||||||
@@ -608,13 +778,34 @@ func parseLimit(s string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
||||||
modelIDs := []string{}
|
creds, err := s.currentCredentials()
|
||||||
if creds, err := s.currentCredentials(); err == nil && creds.HasAPIKey() {
|
if err != nil {
|
||||||
if client, cerr := s.zhanluClient(); cerr == nil {
|
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
|
||||||
if fetched, merr := client.Models(r.Context(), creds.APIKey); merr == nil && len(fetched) > 0 {
|
return
|
||||||
modelIDs = fetched
|
|
||||||
}
|
}
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelIDs, err := client.Models(r.Context(), creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
msg := "zhanlu model list failed"
|
||||||
|
if s.cfg.Debug {
|
||||||
|
msg = redactSensitive(err.Error())
|
||||||
|
}
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_models_failed")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
data := make([]map[string]any, 0, len(modelIDs))
|
data := make([]map[string]any, 0, len(modelIDs))
|
||||||
for _, model := range modelIDs {
|
for _, model := range modelIDs {
|
||||||
@@ -623,6 +814,55 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// callUpstream performs the shared upstream request sequence for
|
||||||
|
// /v1/chat/completions and /v1/responses: provision an API key on demand when
|
||||||
|
// the resolved credentials lack one, build the HTTP/1.1 zhanlu client, POST
|
||||||
|
// the marshalled chat-completions body to the gateway, and handle upstream
|
||||||
|
// errors uniformly (OpenAI error + stats record). creds is the credential set
|
||||||
|
// already resolved by the caller. On success it returns the upstream response
|
||||||
|
// (caller closes Body); on failure it writes the error and records the failed
|
||||||
|
// request, returning ok=false.
|
||||||
|
func (s *Server) callUpstream(w http.ResponseWriter, r *http.Request, creds auth.Credentials, model string, body []byte, clientWantsStream bool, start time.Time) (resp *http.Response, ok bool) {
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
var err error
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
resp, err = client.ChatCompletions(r.Context(), creds.APIKey, body)
|
||||||
|
if err != nil {
|
||||||
|
msg := "zhanlu upstream request failed"
|
||||||
|
if s.cfg.Debug {
|
||||||
|
msg = redactSensitive(err.Error())
|
||||||
|
}
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
|
||||||
|
s.record(model, clientWantsStream, nil, "upstream_error", start)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
resp.Body.Close()
|
||||||
|
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
|
||||||
|
if s.cfg.Debug && len(b) > 0 {
|
||||||
|
msg += ": " + string(b)
|
||||||
|
}
|
||||||
|
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
|
||||||
|
s.record(model, clientWantsStream, nil, "upstream_error", start)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return resp, true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||||
creds, err := s.currentCredentials()
|
creds, err := s.currentCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -645,43 +885,11 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
|
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resp, ok := s.callUpstream(w, r, creds, req.Model, body, clientWantsStream, start)
|
||||||
if !creds.HasAPIKey() {
|
if !ok {
|
||||||
creds, err = s.provisionCredentials(r.Context(), creds)
|
|
||||||
if err != nil {
|
|
||||||
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cfg.Credentials = creds
|
|
||||||
_ = s.st.SaveCredentials(creds)
|
|
||||||
}
|
|
||||||
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
|
||||||
client, err := s.zhanluClientWithBase(modelBaseURL)
|
|
||||||
if err != nil {
|
|
||||||
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
|
|
||||||
if err != nil {
|
|
||||||
msg := "zhanlu upstream request failed"
|
|
||||||
if s.cfg.Debug {
|
|
||||||
msg = redactSensitive(err.Error())
|
|
||||||
}
|
|
||||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
|
|
||||||
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
||||||
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
|
|
||||||
if s.cfg.Debug && len(b) > 0 {
|
|
||||||
msg += ": " + string(b)
|
|
||||||
}
|
|
||||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
|
|
||||||
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if clientWantsStream {
|
if clientWantsStream {
|
||||||
usage, status := s.proxyStream(w, resp)
|
usage, status := s.proxyStream(w, resp)
|
||||||
s.record(req.Model, true, usage, status, start)
|
s.record(req.Model, true, usage, status, start)
|
||||||
@@ -765,21 +973,12 @@ func sseDataPayload(line string) string {
|
|||||||
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (any, string) {
|
// chatStreamChunk is one SSE data event from the upstream chat-completions
|
||||||
var content, reasoning, id string
|
// stream. It is shared by the non-streaming aggregation paths of
|
||||||
var usage any
|
// /v1/chat/completions (aggregateStream) and /v1/responses
|
||||||
finishReason := "stop"
|
// (aggregateResponsesStream), which previously redeclared this anonymous
|
||||||
type toolCall struct {
|
// struct inline in each function.
|
||||||
ID string `json:"id"`
|
type chatStreamChunk struct {
|
||||||
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 struct {
|
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
@@ -799,7 +998,23 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
|||||||
FinishReason *string `json:"finish_reason"`
|
FinishReason *string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage any `json:"usage"`
|
Usage any `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (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 {
|
if err := json.Unmarshal(chunk, &event); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -842,11 +1057,14 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
|
|||||||
}
|
}
|
||||||
message := map[string]any{"role": "assistant", "content": content}
|
message := map[string]any{"role": "assistant", "content": content}
|
||||||
if len(toolCalls) > 0 {
|
if len(toolCalls) > 0 {
|
||||||
ordered := make([]*toolCall, 0, len(toolCalls))
|
indices := make([]int, 0, len(toolCalls))
|
||||||
for i := 0; i < len(toolCalls); i++ {
|
for i := range toolCalls {
|
||||||
if call := toolCalls[i]; call != nil {
|
indices = append(indices, i)
|
||||||
ordered = append(ordered, call)
|
|
||||||
}
|
}
|
||||||
|
sort.Ints(indices)
|
||||||
|
ordered := make([]*toolCall, 0, len(toolCalls))
|
||||||
|
for _, i := range indices {
|
||||||
|
ordered = append(ordered, toolCalls[i])
|
||||||
}
|
}
|
||||||
message["tool_calls"] = ordered
|
message["tool_calls"] = ordered
|
||||||
if content == "" {
|
if content == "" {
|
||||||
@@ -873,26 +1091,19 @@ func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
|
|||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
|
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
payload := sseDataPayload(scanner.Text())
|
||||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
||||||
if payload == "" || payload == "[DONE]" {
|
if payload == "" || payload == "[DONE]" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var upstreamError map[string]any
|
|
||||||
if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
|
|
||||||
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
|
|
||||||
}
|
|
||||||
if !json.Valid([]byte(payload)) {
|
|
||||||
return errors.New("zhanlu stream contained invalid JSON")
|
|
||||||
}
|
|
||||||
if err := fn([]byte(payload)); err != nil {
|
if err := fn([]byte(payload)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return scanner.Err()
|
err := scanner.Err()
|
||||||
|
if err != nil && errors.Is(err, bufio.ErrTooLong) {
|
||||||
|
return errors.New("zhanlu stream chunk exceeded 2MB limit")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) currentCredentials() (auth.Credentials, error) {
|
func (s *Server) currentCredentials() (auth.Credentials, error) {
|
||||||
@@ -960,13 +1171,6 @@ func mask(s string) string {
|
|||||||
return s[:4] + "****" + s[len(s)-4:]
|
return s[:4] + "****" + s[len(s)-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(a, b string) string {
|
|
||||||
if a != "" {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// humanNum renders an integer-like value with K/M/B suffixes for compact,
|
// humanNum renders an integer-like value with K/M/B suffixes for compact,
|
||||||
// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 ->
|
// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 ->
|
||||||
// "1.5M"). Values below 1000 are shown as plain integers. It accepts int,
|
// "1.5M"). Values below 1000 are shown as plain integers. It accepts int,
|
||||||
@@ -1018,463 +1222,3 @@ func redactQueryValue(s, key string) string {
|
|||||||
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
|
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>湛卢代理登录</title>
|
|
||||||
<style>
|
|
||||||
:root{
|
|
||||||
color-scheme:light;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
||||||
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
|
|
||||||
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
|
||||||
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
|
|
||||||
--ok:#059669; --err:#dc2626;
|
|
||||||
--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>`))
|
|
||||||
|
|
||||||
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>湛卢登录结果</title>
|
|
||||||
<style>
|
|
||||||
:root{
|
|
||||||
color-scheme:light;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
||||||
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
|
|
||||||
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
|
||||||
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
|
||||||
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
|
||||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
||||||
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
|
|
||||||
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
|
|
||||||
.mark svg{width:26px;height:26px}
|
|
||||||
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
|
|
||||||
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
|
|
||||||
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
|
|
||||||
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
|
|
||||||
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
|
|
||||||
.btn:hover{background:var(--accent-hover)}
|
|
||||||
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="card">
|
|
||||||
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
|
|
||||||
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
|
|
||||||
</div>
|
|
||||||
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
|
|
||||||
<p>{{.Message}}</p>
|
|
||||||
<a class="btn" href="/admin">返回管理后台</a>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|
||||||
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
|
|
||||||
"rate": func(cached, prompt int64) string {
|
|
||||||
if prompt <= 0 {
|
|
||||||
return "0%"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
|
|
||||||
},
|
|
||||||
"human": humanNum,
|
|
||||||
}).Parse(`<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>湛卢代理管理后台</title>
|
|
||||||
<style>
|
|
||||||
:root{
|
|
||||||
color-scheme:light;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
||||||
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
|
|
||||||
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
|
|
||||||
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
|
|
||||||
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
|
|
||||||
--radius:16px; --radius-sm:10px;
|
|
||||||
}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
|
|
||||||
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
|
|
||||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
||||||
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
|
|
||||||
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
|
||||||
.brand{display:flex;align-items:center;gap:10px}
|
|
||||||
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
|
|
||||||
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
|
|
||||||
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
|
|
||||||
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
|
|
||||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
|
|
||||||
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
||||||
.btn-primary{background:var(--accent);color:#fff}
|
|
||||||
.btn-primary:hover{background:var(--accent-hover)}
|
|
||||||
.btn-primary:active{transform:translateY(1px)}
|
|
||||||
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
|
|
||||||
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
|
||||||
.btn-ghost:active{transform:translateY(1px)}
|
|
||||||
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
|
|
||||||
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
|
|
||||||
.tab:hover{color:var(--ink)}
|
|
||||||
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
|
|
||||||
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
||||||
.tabpanel{display:none;min-width:0}
|
|
||||||
.tabpanel.active{display:block;min-width:0}
|
|
||||||
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
|
|
||||||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
|
|
||||||
.panel.disabled{background:#eef0f3}
|
|
||||||
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
|
||||||
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
|
|
||||||
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
|
|
||||||
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
|
|
||||||
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
|
||||||
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
|
|
||||||
tbody tr:last-child td{border-bottom:0}
|
|
||||||
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
|
|
||||||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
||||||
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
|
|
||||||
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
|
|
||||||
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
|
|
||||||
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
|
|
||||||
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
|
|
||||||
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
|
|
||||||
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
|
|
||||||
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
|
|
||||||
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
|
|
||||||
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
|
||||||
.muted{color:var(--muted);font-size:13px;line-height:1.6}
|
|
||||||
.scroll{overflow-x:auto;min-width:0}
|
|
||||||
.empty{color:var(--muted);font-size:13px;padding:8px 0}
|
|
||||||
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
|
|
||||||
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
|
|
||||||
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
|
||||||
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
||||||
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
||||||
.page-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
||||||
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
|
|
||||||
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
|
|
||||||
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
|
|
||||||
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
|
|
||||||
form{display:grid;gap:18px}
|
|
||||||
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
|
|
||||||
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
|
|
||||||
input::placeholder{color:var(--muted)}
|
|
||||||
input:hover{border-color:#bdc2cc}
|
|
||||||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
||||||
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
|
|
||||||
.btn-primary.full{width:100%}
|
|
||||||
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
|
|
||||||
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
|
|
||||||
.status[data-state="ok"]{color:var(--ok)}
|
|
||||||
.status[data-state="err"]{color:var(--err)}
|
|
||||||
.status[data-state="busy"]{color:var(--accent)}
|
|
||||||
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
|
|
||||||
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
|
||||||
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
|
|
||||||
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<header class="top">
|
|
||||||
<div>
|
|
||||||
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
|
|
||||||
<h1>湛卢代理管理</h1>
|
|
||||||
</div>
|
|
||||||
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="tabs" role="tablist">
|
|
||||||
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
|
||||||
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="tabpanel active" data-tab="stats" role="tabpanel">
|
|
||||||
<div class="sub-actions">
|
|
||||||
<button class="btn btn-ghost" id="refresh">刷新</button>
|
|
||||||
<button class="btn btn-primary" id="reset">重置统计</button>
|
|
||||||
</div>
|
|
||||||
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
|
|
||||||
<div class="panel">
|
|
||||||
<h2>总览</h2>
|
|
||||||
<div class="grid4">
|
|
||||||
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
|
|
||||||
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
|
|
||||||
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
|
|
||||||
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
|
|
||||||
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel" style="margin-top:20px">
|
|
||||||
<h2>按模型</h2>
|
|
||||||
{{if .Stats.PerModel}}
|
|
||||||
<div class="scroll"><table>
|
|
||||||
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="panel" style="margin-top:20px">
|
|
||||||
<h2>按日</h2>
|
|
||||||
{{if .Stats.Daily}}
|
|
||||||
<div id="daily">
|
|
||||||
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
|
|
||||||
</div>
|
|
||||||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
|
||||||
</div>
|
|
||||||
<div class="panel" style="margin-top:20px">
|
|
||||||
<h2>最近请求</h2>
|
|
||||||
{{if .Stats.Recent}}
|
|
||||||
<div class="scroll"><table id="recent">
|
|
||||||
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
<div class="pager" id="recent-pager"></div>
|
|
||||||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="tabpanel" data-tab="login" role="tabpanel">
|
|
||||||
<div class="login-card">
|
|
||||||
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
|
||||||
<form id="phone-form">
|
|
||||||
<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 === 'login') activate('login');
|
|
||||||
|
|
||||||
var rows = document.querySelectorAll('#daily .bar');
|
|
||||||
var max = 1;
|
|
||||||
for (var i = 0; i < rows.length; i++) {
|
|
||||||
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
|
|
||||||
if (t > max) max = t;
|
|
||||||
}
|
|
||||||
for (var j = 0; j < rows.length; j++) {
|
|
||||||
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
|
|
||||||
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
|
|
||||||
}
|
|
||||||
var refresh = document.getElementById('refresh');
|
|
||||||
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
|
|
||||||
var reset = document.getElementById('reset');
|
|
||||||
if (reset) reset.addEventListener('click', function () {
|
|
||||||
if (!confirm('确定清空所有统计数据?')) return;
|
|
||||||
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// paginate the recent-requests table (data is already rendered server-side)
|
|
||||||
(function () {
|
|
||||||
var tbody = document.querySelector('#recent tbody');
|
|
||||||
var pager = document.getElementById('recent-pager');
|
|
||||||
if (!tbody || !pager) return;
|
|
||||||
var rows = tbody.querySelectorAll('tr');
|
|
||||||
if (rows.length === 0) { pager.style.display = 'none'; return; }
|
|
||||||
var pageSize = 10;
|
|
||||||
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
|
||||||
var page = 1;
|
|
||||||
function pageList(c, t) {
|
|
||||||
var p = [];
|
|
||||||
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
|
|
||||||
p.push(1);
|
|
||||||
if (c > 3) p.push('…');
|
|
||||||
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
|
|
||||||
for (var m = s; m <= e; m++) p.push(m);
|
|
||||||
if (c < t - 2) p.push('…');
|
|
||||||
p.push(t);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
function render() {
|
|
||||||
var start = (page - 1) * pageSize;
|
|
||||||
for (var i = 0; i < rows.length; i++) {
|
|
||||||
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
|
|
||||||
}
|
|
||||||
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '>‹</button>';
|
|
||||||
var list = pageList(page, totalPages);
|
|
||||||
for (var n = 0; n < list.length; n++) {
|
|
||||||
var item = list[n];
|
|
||||||
if (item === '…') html += '<span class="page-btn dots">…</span>';
|
|
||||||
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
|
|
||||||
}
|
|
||||||
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
|
|
||||||
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
|
|
||||||
pager.innerHTML = html;
|
|
||||||
}
|
|
||||||
pager.addEventListener('click', function (ev) {
|
|
||||||
var btn = ev.target.closest('.page-btn');
|
|
||||||
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
|
|
||||||
if (btn.dataset.act === 'prev' && page > 1) page--;
|
|
||||||
else if (btn.dataset.act === 'next' && page < totalPages) page++;
|
|
||||||
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
})();
|
|
||||||
|
|
||||||
var logout = document.getElementById('logout-button');
|
|
||||||
if (logout) logout.addEventListener('click', async function () {
|
|
||||||
await fetch('/api/logout', { method: 'POST' });
|
|
||||||
window.location.href = '/login';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var statusEl = document.getElementById('status');
|
|
||||||
var form = document.getElementById('phone-form');
|
|
||||||
var codeButton = document.getElementById('code-button');
|
|
||||||
var secret = '';
|
|
||||||
var countdown = 0;
|
|
||||||
var countdownTimer = null;
|
|
||||||
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
|
||||||
function startCountdown() {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,6 +272,141 @@ func TestStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
// totNum extracts an int from a JSON-decoded numeric value (float64).
|
||||||
func totNum(v any) int {
|
func totNum(v any) int {
|
||||||
f, _ := v.(float64)
|
f, _ := v.(float64)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||||
@@ -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>
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -93,8 +94,8 @@ func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team
|
|||||||
if strings.TrimSpace(email) == "" {
|
if strings.TrimSpace(email) == "" {
|
||||||
return "", fmt.Errorf("profile email is required to provision an API key")
|
return "", fmt.Errorf("profile email is required to provision an API key")
|
||||||
}
|
}
|
||||||
org := firstNonEmpty(organization, "未配置")
|
org := util.FirstNonEmpty(organization, "未配置")
|
||||||
tm := firstNonEmpty(team, "未配置")
|
tm := util.FirstNonEmpty(team, "未配置")
|
||||||
// Field order matters: the SM2 signature covers the exact JSON body bytes,
|
// Field order matters: the SM2 signature covers the exact JSON body bytes,
|
||||||
// matching the plugin's JSON.stringify({email, organization, team}).
|
// matching the plugin's JSON.stringify({email, organization, team}).
|
||||||
body, err := json.Marshal(struct {
|
body, err := json.Marshal(struct {
|
||||||
@@ -195,7 +196,7 @@ func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
|
|||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
models := make([]string, 0, len(out.Data))
|
models := make([]string, 0, len(out.Data))
|
||||||
for _, m := range out.Data {
|
for _, m := range out.Data {
|
||||||
id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
|
id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
|
||||||
if id == "" || seen[id] {
|
if id == "" || seen[id] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@ func (c *Client) setPluginHeaders(req *http.Request) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("plugin_type", "zhanlu_ide")
|
req.Header.Set("plugin_type", "zhanlu_ide")
|
||||||
req.Header.Set("plugin_version", c.PluginVersion)
|
req.Header.Set("plugin_version", c.PluginVersion)
|
||||||
req.Header.Set("request", randomRequestID())
|
req.Header.Set("request", util.RandomRequestID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(resp *http.Response, out any) error {
|
func decodeJSON(resp *http.Response, out any) error {
|
||||||
@@ -237,15 +238,6 @@ func findString(m map[string]any, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
|
||||||
for _, v := range values {
|
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
func randomAlnum(n int) string {
|
func randomAlnum(n int) string {
|
||||||
@@ -256,13 +248,3 @@ func randomAlnum(n int) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
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:])
|
|
||||||
}
|
|
||||||
|
|||||||
Executable
+96
@@ -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
|
||||||
Reference in New Issue
Block a user