7 Commits
Author SHA1 Message Date
m1saka 22d78c4576 Add recent API request recorder with admin tab and /api/recent endpoint
build / build (push) Successful in 2m31s
Record the last 10 /v1/ API requests (including errors) in an in-memory
ring buffer. Each entry captures request headers (Authorization masked),
request body, response status, and response body — all up to 1 MB.

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

Add rawJSONToString (re-encodes non-string values as JSON strings)
and outputToString (extracts text from content-parts arrays, re-encodes
other non-string values). Add 3 regression tests covering object
arguments, array output, and object output.
2026-08-23 14:15:01 +08:00
m1saka f51ec09a09 gofmt: remove trailing blank lines in client.go
build / build (push) Successful in 2m31s
2026-08-21 15:25:06 +08:00
m1saka b9a3f8d5cd Mirror Zhanlu client: GLM tool_stream, shared v4 UUID request id, drop chat state:ERROR
build / build (push) Successful in 2m26s
2026-08-21 14:27:37 +08:00
m1saka a0a2049440 Add 1d/7d/all time-range filter to admin by-model stats
build / build (push) Successful in 2m32s
By-model panel gains a segmented 1天/7天/全部 control. Selecting a
range fetches /api/stats?since=<RFC3339> and re-renders only that table
client-side, replicating the human/rate template helpers in JS; the
existing /api/stats since param already drives the server-side filter.

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

Verified against OpenAI migration guide and Python SDK Response model:
- Echo back required fields parallel_tool_calls/tool_choice/tools
- Include content:[] in reasoning items, logprobs:[] in output_text parts
- Support function_call/function_call_output multi-turn input items
- Map usage fields prompt_tokens→input_tokens, completion_tokens→output_tokens
2026-08-20 10:02:11 +08:00
19 changed files with 3137 additions and 728 deletions
+25 -1
View File
@@ -5,7 +5,7 @@
当前实现包含:
- 登录页支持插件默认的移动云手机号验证码登录:验证码校验后按插件流程调用 `/api/acepilot/zhanlu/v1/login` 获取用户资料,再通过 SM2 签名调用 `/user/api/v2/external/key/get-or-create` 换取模型 API Key,凭据自动保存到本地 JSON。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions`)。
- OpenAI 兼容接口:`/v1/models`(优先从 `/gateway/v1/model/info` 拉取模型列表)、`/v1/chat/completions`(携带 `Authorization: Bearer <apiKey>` 请求 `{modelBaseUrl}/chat/completions``/v1/responses`Responses API,自动转换请求/响应格式,兼容新版 SDK 与 AI 编码工具)
- 湛卢登录签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`,用于 v1.4.2 的 v1/login 认证。
- 模型 API Key 换取签名逻辑:SM3 摘要 + SM2 签名(`X-Auth-Signature`/`X-Auth-Timestamp`/`X-Auth-Nonce`)。
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
@@ -169,6 +169,30 @@ curl -N http://127.0.0.1:8080/v1/chat/completions `
请求中的 `tools``tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls``role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。
### Responses API
代理还兼容 OpenAI Responses API`POST /v1/responses`),支持使用该 API 的客户端(如新版 OpenAI SDK、Cursor 等 AI 编码工具)直接对接。代理会将 Responses API 请求格式转换为上游 chat/completions 格式,再将响应转回 Responses 格式。
请求中的 `input`(字符串或消息数组)、`instructions`(系统提示词)、`max_output_tokens``temperature``top_p``tools``tool_choice``response_format` 等参数均会翻译并透传给上游。`tools` 格式自动从 Responses 扁平结构转换为 Chat Completions 嵌套 `{function:{…}}` 结构。多轮对话中的 `function_call``function_call_output` 输入项也会自动转换为对应的 assistant `tool_calls``role: tool` 消息。
非流式:
```powershell
curl http://127.0.0.1:8080/v1/responses `
-H "Content-Type: application/json" `
-d '{"model":"zhanlu/auto","input":"hello","stream":false}'
```
流式:
```powershell
curl -N http://127.0.0.1:8080/v1/responses `
-H "Content-Type: application/json" `
-d '{"model":"zhanlu/auto","input":"hello","stream":true}'
```
流式响应返回标准 Responses API SSE 事件序列(`response.created``response.output_item.added``response.output_text.delta``response.output_text.done``response.output_item.done``response.completed`),思考模型额外包含 `response.reasoning_summary_text.delta` 事件,工具调用包含 `response.function_call_arguments.delta` 事件。Token 统计同样记录在 SQLite 中。
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`
```powershell
+13 -14
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
@@ -39,9 +42,8 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b := make([]byte, 1024)
n, _ := resp.Body.Read(b)
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var exchange ExchangeResponse
@@ -49,11 +51,17 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
return Profile{}, err
}
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
if exchange.State != "" && exchange.State != "OK" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
@@ -95,12 +103,3 @@ type ExchangeResponse struct {
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return "unknown error"
}
+4 -12
View File
@@ -6,6 +6,7 @@ import (
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Config struct {
@@ -25,7 +26,7 @@ type Config struct {
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
StreamIdleTimeout time.Duration
Debug bool
Credentials auth.Credentials
}
@@ -33,7 +34,7 @@ type Config struct {
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileLoginBaseURL: util.FirstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
@@ -48,7 +49,7 @@ func Load() (Config, error) {
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Credentials = auth.Credentials{
@@ -81,15 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+396
View File
@@ -0,0 +1,396 @@
package openai
import (
"encoding/json"
"errors"
"strings"
)
// --- Responses API request parsing & conversion ---
// ParseResponsesRequest converts a raw OpenAI Responses API (POST /v1/responses)
// request body into a ChatCompletionRequest suitable for the upstream gateway.
// It translates input→messages, instructions→system message, max_output_tokens→
// max_tokens, and reshapes tools from the Responses flat format to the Chat
// Completions nested {function:{…}} format.
func ParseResponsesRequest(body []byte) (ChatCompletionRequest, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return ChatCompletionRequest{}, err
}
var req ChatCompletionRequest
if v, ok := raw["model"]; ok {
_ = json.Unmarshal(v, &req.Model)
}
if v, ok := raw["stream"]; ok {
_ = json.Unmarshal(v, &req.Stream)
}
// Build messages from instructions + input.
messages := make([]map[string]any, 0, 4)
if v, ok := raw["instructions"]; ok {
var s string
if json.Unmarshal(v, &s) == nil && s != "" {
messages = append(messages, map[string]any{"role": "system", "content": s})
}
}
if v, ok := raw["input"]; ok {
msgs, err := responsesInputToMessages(v)
if err != nil {
return req, err
}
messages = append(messages, msgs...)
}
req.Messages = messages
// Extra: renamed + pass-through fields sent to upstream as-is.
extra := map[string]json.RawMessage{}
if v, ok := raw["max_output_tokens"]; ok {
extra["max_tokens"] = v
}
for _, key := range []string{
"temperature", "top_p", "top_k", "frequency_penalty",
"presence_penalty", "stop", "seed", "user",
} {
if v, ok := raw[key]; ok {
extra[key] = v
}
}
if v, ok := raw["tools"]; ok {
if translated, err := translateResponsesTools(v); err == nil {
extra["tools"] = translated
} else {
extra["tools"] = v // fall back to pass-through
}
}
if v, ok := raw["tool_choice"]; ok {
extra["tool_choice"] = v
}
// Structured Outputs: Responses API uses text.format instead of
// response_format. Translate text.format → response_format for the
// upstream Chat Completions endpoint.
if v, ok := raw["text"]; ok {
if rf, err := translateTextFormat(v); err == nil {
extra["response_format"] = rf
}
}
req.Extra = extra
return req, nil
}
// translateTextFormat converts the Responses API "text" field (containing a
// "format" sub-object) into a Chat Completions response_format value.
// - {text:{format:{type:"json_object"}}} → {type:"json_object"}
// - {text:{format:{type:"json_schema",name,schema,strict}}} →
// {type:"json_schema",json_schema:{name,schema,strict}}
func translateTextFormat(textRaw json.RawMessage) (json.RawMessage, error) {
var text struct {
Format map[string]any `json:"format"`
}
if err := json.Unmarshal(textRaw, &text); err != nil {
return nil, err
}
if len(text.Format) == 0 {
return nil, errors.New("empty text.format")
}
t, _ := text.Format["type"].(string)
switch t {
case "json_object":
return json.Marshal(map[string]any{"type": "json_object"})
case "json_schema":
js := map[string]any{}
for _, k := range []string{"name", "schema", "strict"} {
if v, ok := text.Format[k]; ok {
js[k] = v
}
}
return json.Marshal(map[string]any{"type": "json_schema", "json_schema": js})
default:
return json.Marshal(text.Format) // pass through unknown types
}
}
// ResponsesMeta holds fields from the Responses API request that should be
// echoed back in the response object (the OpenAI SDK requires them).
type ResponsesMeta struct {
ParallelToolCalls any
ToolChoice any
Tools any
Temperature any
TopP any
MaxOutputTokens any
}
// ParseResponsesMeta extracts echo-back fields from the raw request body.
func ParseResponsesMeta(body []byte) ResponsesMeta {
var m map[string]any
_ = json.Unmarshal(body, &m)
meta := ResponsesMeta{
ParallelToolCalls: false,
ToolChoice: "auto",
Tools: []any{},
}
if v, ok := m["parallel_tool_calls"]; ok {
meta.ParallelToolCalls = v
}
if v, ok := m["tool_choice"]; ok {
meta.ToolChoice = v
}
// Tools are echoed back in the Responses API flat format (not the
// translated Chat Completions format).
if v, ok := m["tools"]; ok {
meta.Tools = v
} else {
meta.Tools = []any{}
}
if v, ok := m["temperature"]; ok {
meta.Temperature = v
}
if v, ok := m["top_p"]; ok {
meta.TopP = v
}
if v, ok := m["max_output_tokens"]; ok {
meta.MaxOutputTokens = v
}
return meta
}
// responsesInputToMessages converts the Responses API "input" field (which may
// be a plain string or an array of input items) into Chat Completions messages.
func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) {
// Case 1: input is a plain string.
var s string
if json.Unmarshal(input, &s) == nil {
return []map[string]any{{"role": "user", "content": s}}, nil
}
// Case 2: input is an array of items.
var items []map[string]json.RawMessage
if err := json.Unmarshal(input, &items); err != nil {
return nil, err
}
messages := make([]map[string]any, 0, len(items))
for _, item := range items {
// Determine the type — most items are message-like with a role.
var role string
if v, ok := item["role"]; ok {
_ = json.Unmarshal(v, &role)
}
var itemType string
if v, ok := item["type"]; ok {
_ = json.Unmarshal(v, &itemType)
}
switch {
case itemType == "function_call":
// An assistant tool call from a previous turn.
msg := map[string]any{"role": "assistant"}
tc := map[string]any{"type": "function"}
inner := map[string]any{}
if v, ok := item["name"]; ok {
var name string
_ = json.Unmarshal(v, &name)
inner["name"] = name
}
if v, ok := item["arguments"]; ok {
inner["arguments"] = rawJSONToString(v)
}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
tc["id"] = id
}
tc["function"] = inner
msg["tool_calls"] = []any{tc}
messages = append(messages, msg)
case itemType == "function_call_output":
// A tool result from a previous turn.
msg := map[string]any{"role": "tool"}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
msg["tool_call_id"] = id
}
if v, ok := item["output"]; ok {
msg["content"] = outputToString(v)
}
messages = append(messages, msg)
default:
// Standard message item with role + content.
if role == "" {
role = "user"
}
// "developer" maps to "system" for broad upstream compatibility.
if role == "developer" {
role = "system"
}
msg := map[string]any{"role": role}
if v, ok := item["content"]; ok {
msg["content"] = convertContentParts(v)
} else {
msg["content"] = ""
}
messages = append(messages, msg)
}
}
return messages, nil
}
// convertContentParts converts a Responses API content field (string or array
// of content parts) into the Chat Completions content format.
func convertContentParts(raw json.RawMessage) any {
// Content is a plain string.
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// Content is an array of parts.
var parts []map[string]any
if json.Unmarshal(raw, &parts) != nil {
return string(raw) // fallback
}
result := make([]map[string]any, 0, len(parts))
for _, p := range parts {
pt, _ := p["type"].(string)
switch pt {
case "input_text", "output_text", "text":
result = append(result, map[string]any{"type": "text", "text": p["text"]})
case "input_image":
img := map[string]any{"type": "image_url"}
if url, ok := p["image_url"]; ok {
img["image_url"] = map[string]any{"url": url}
} else if d, ok := p["image"]; ok {
img["image_url"] = map[string]any{"url": d}
}
result = append(result, img)
default:
// Pass through unknown part types as-is.
result = append(result, p)
}
}
if len(result) == 0 {
return ""
}
return result
}
// rawJSONToString converts a json.RawMessage to a string. If the value is
// already a JSON string, it is used directly. Any other JSON value (object,
// array, number, bool) is re-encoded as a JSON string so it can populate
// fields that require a string, such as tool_calls[].function.arguments.
// This prevents silent data loss when a field arrives as a non-string type.
func rawJSONToString(v json.RawMessage) string {
var s string
if json.Unmarshal(v, &s) == nil {
return s
}
var anyValue any
if json.Unmarshal(v, &anyValue) == nil {
if b, err := json.Marshal(anyValue); err == nil {
return string(b)
}
}
return string(v) // last resort: raw bytes
}
// outputToString converts a function_call_output "output" value to a string
// for the Chat Completions tool message content. The output may be:
// - a plain string (used directly)
// - an array of content parts (text extracted and concatenated)
// - any other JSON value (re-encoded as a JSON string)
func outputToString(v json.RawMessage) string {
var s string
if json.Unmarshal(v, &s) == nil {
return s
}
// Try array of content parts — extract text from each part.
var parts []map[string]any
if json.Unmarshal(v, &parts) == nil {
var sb strings.Builder
for _, p := range parts {
if t, _ := p["text"].(string); t != "" {
sb.WriteString(t)
}
}
if sb.Len() > 0 {
return sb.String()
}
}
// Fallback: re-encode as a JSON string.
var anyValue any
if json.Unmarshal(v, &anyValue) == nil {
if b, err := json.Marshal(anyValue); err == nil {
return string(b)
}
}
return string(v)
}
// translateResponsesTools converts tools from the Responses API flat format
// to the Chat Completions nested {type:"function",function:{…}} format.
func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
var tools []map[string]any
if err := json.Unmarshal(raw, &tools); err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
tt, _ := t["type"].(string)
if tt != "function" && tt != "" {
// Non-function tool types (web_search, file_search, etc.) —
// pass through as-is; upstream may or may not support them.
out = append(out, t)
continue
}
fn := map[string]any{}
for _, key := range []string{"name", "description", "parameters", "strict"} {
if v, ok := t[key]; ok {
fn[key] = v
}
}
out = append(out, map[string]any{"type": "function", "function": fn})
}
return json.Marshal(out)
}
// --- Responses API response building ---
// UsageToResponses converts a Chat Completions usage value (as decoded by
// json.Unmarshal into any) to the Responses API usage field names
// (input_tokens / output_tokens instead of prompt_tokens / completion_tokens).
func UsageToResponses(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return v
}
var m map[string]any
if json.Unmarshal(b, &m) != nil {
return v
}
out := map[string]any{}
if pt, ok := m["prompt_tokens"]; ok {
out["input_tokens"] = pt
}
if ct, ok := m["completion_tokens"]; ok {
out["output_tokens"] = ct
}
if tt, ok := m["total_tokens"]; ok {
out["total_tokens"] = tt
}
if d, ok := m["prompt_tokens_details"]; ok {
out["input_tokens_details"] = d
} else if d, ok := m["input_tokens_details"]; ok {
out["input_tokens_details"] = d
}
if d, ok := m["completion_tokens_details"]; ok {
out["output_tokens_details"] = d
} else if d, ok := m["output_tokens_details"]; ok {
out["output_tokens_details"] = d
}
return out
}
+389
View File
@@ -0,0 +1,389 @@
package openai
import (
"encoding/json"
"testing"
)
func TestParseResponsesRequest_StringInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if req.Model != "GLM-4.7" {
t.Fatalf("model = %q", req.Model)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "user" {
t.Fatalf("role = %v", req.Messages[0]["role"])
}
if req.Messages[0]["content"] != "hello world" {
t.Fatalf("content = %v", req.Messages[0]["content"])
}
}
func TestParseResponsesRequest_Instructions(t *testing.T) {
body := `{"model":"GLM-4.7","instructions":"be helpful","input":"hi"}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "system" {
t.Fatalf("first role = %v", req.Messages[0]["role"])
}
if req.Messages[0]["content"] != "be helpful" {
t.Fatalf("first content = %v", req.Messages[0]["content"])
}
if req.Messages[1]["role"] != "user" {
t.Fatalf("second role = %v", req.Messages[1]["role"])
}
}
func TestParseResponsesRequest_ArrayInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":"hello"},
{"role":"assistant","content":"hi there"},
{"role":"user","content":"how are you?"}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 3 {
t.Fatalf("messages = %d items", len(req.Messages))
}
if req.Messages[0]["role"] != "user" || req.Messages[0]["content"] != "hello" {
t.Fatalf("msg[0] = %v", req.Messages[0])
}
if req.Messages[1]["role"] != "assistant" || req.Messages[1]["content"] != "hi there" {
t.Fatalf("msg[1] = %v", req.Messages[1])
}
if req.Messages[2]["role"] != "user" || req.Messages[2]["content"] != "how are you?" {
t.Fatalf("msg[2] = %v", req.Messages[2])
}
}
func TestParseResponsesRequest_ContentParts(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":[
{"type":"input_text","text":"describe this"},
{"type":"input_image","image_url":"data:image/png;base64,abc"}
]}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
content, ok := req.Messages[0]["content"].([]map[string]any)
if !ok {
t.Fatalf("content type = %T", req.Messages[0]["content"])
}
if len(content) != 2 {
t.Fatalf("content parts = %d", len(content))
}
if content[0]["type"] != "text" || content[0]["text"] != "describe this" {
t.Fatalf("content[0] = %v", content[0])
}
if content[1]["type"] != "image_url" {
t.Fatalf("content[1] type = %v", content[1])
}
}
func TestParseResponsesRequest_MaxOutputTokens(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","max_output_tokens":500}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if v, ok := req.Extra["max_tokens"]; !ok {
t.Fatal("max_tokens not in Extra")
} else {
var n int
_ = json.Unmarshal(v, &n)
if n != 500 {
t.Fatalf("max_tokens = %d", n)
}
}
}
func TestParseResponsesRequest_Tools(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","tools":[
{"type":"function","name":"get_weather","description":"get weather","parameters":{"type":"object"}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
raw, ok := req.Extra["tools"]
if !ok {
t.Fatal("tools not in Extra")
}
var tools []map[string]any
if err := json.Unmarshal(raw, &tools); err != nil {
t.Fatal(err)
}
if len(tools) != 1 {
t.Fatalf("tools = %d", len(tools))
}
if tools[0]["type"] != "function" {
t.Fatalf("tool type = %v", tools[0]["type"])
}
fn, ok := tools[0]["function"].(map[string]any)
if !ok {
t.Fatalf("function type = %T", tools[0]["function"])
}
if fn["name"] != "get_weather" {
t.Fatalf("function name = %v", fn["name"])
}
}
func TestParseResponsesRequest_FunctionCallInput(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"role":"user","content":"what's the weather?"},
{"type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
{"type":"function_call_output","call_id":"call_123","output":"sunny 72F"}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 3 {
t.Fatalf("messages = %d items", len(req.Messages))
}
// First message is user text
if req.Messages[0]["role"] != "user" {
t.Fatalf("msg[0] role = %v", req.Messages[0]["role"])
}
// Second message is assistant with tool_calls
if req.Messages[1]["role"] != "assistant" {
t.Fatalf("msg[1] role = %v", req.Messages[1]["role"])
}
tcs, ok := req.Messages[1]["tool_calls"].([]any)
if !ok || len(tcs) != 1 {
t.Fatalf("msg[1] tool_calls = %v", req.Messages[1]["tool_calls"])
}
tc, _ := tcs[0].(map[string]any)
if tc["id"] != "call_123" {
t.Fatalf("tool call id = %v", tc["id"])
}
fn, _ := tc["function"].(map[string]any)
if fn["name"] != "get_weather" {
t.Fatalf("function name = %v", fn["name"])
}
// Third message is tool result
if req.Messages[2]["role"] != "tool" {
t.Fatalf("msg[2] role = %v", req.Messages[2]["role"])
}
if req.Messages[2]["tool_call_id"] != "call_123" {
t.Fatalf("msg[2] tool_call_id = %v", req.Messages[2]["tool_call_id"])
}
if req.Messages[2]["content"] != "sunny 72F" {
t.Fatalf("msg[2] content = %v", req.Messages[2]["content"])
}
}
// TestParseResponsesRequest_FunctionCallObjectArguments verifies that when
// function_call.arguments arrives as a JSON object (not a string), it is
// re-encoded as a JSON string instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallObjectArguments(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_456","name":"task","arguments":{"operation":"create","summary":"test"}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 1 {
t.Fatalf("messages = %d items", len(req.Messages))
}
tc, _ := req.Messages[0]["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("tool_calls = %v", req.Messages[0]["tool_calls"])
}
fn, _ := tc[0].(map[string]any)["function"].(map[string]any)
args, _ := fn["arguments"].(string)
if args == "" {
t.Fatalf("arguments was dropped (empty)")
}
// The re-encoded string must be valid JSON containing the original fields.
var parsed map[string]any
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
t.Fatalf("arguments not valid JSON: %v", err)
}
if parsed["operation"] != "create" {
t.Fatalf("operation = %v", parsed["operation"])
}
}
// TestParseResponsesRequest_FunctionCallOutputArray verifies that when
// function_call_output.output arrives as an array of content parts, the
// text is extracted instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallOutputArray(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_789","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
{"type":"function_call_output","call_id":"call_789","output":[{"type":"output_text","text":"sunny 72F"}]}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 2 {
t.Fatalf("messages = %d items", len(req.Messages))
}
toolMsg := req.Messages[1]
if toolMsg["role"] != "tool" {
t.Fatalf("msg[1] role = %v", toolMsg["role"])
}
content, _ := toolMsg["content"].(string)
if content != "sunny 72F" {
t.Fatalf("content = %q, want %q", content, "sunny 72F")
}
}
// TestParseResponsesRequest_FunctionCallOutputObject verifies that when
// function_call_output.output is a bare JSON object, it is re-encoded as a
// JSON string instead of being silently dropped.
func TestParseResponsesRequest_FunctionCallOutputObject(t *testing.T) {
body := `{"model":"GLM-4.7","input":[
{"type":"function_call","call_id":"call_obj","name":"run","arguments":"{}"},
{"type":"function_call_output","call_id":"call_obj","output":{"result":"success","code":200}}
]}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
toolMsg := req.Messages[1]
content, _ := toolMsg["content"].(string)
if content == "" {
t.Fatal("content was dropped (empty)")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
t.Fatalf("content not valid JSON: %v", err)
}
if parsed["result"] != "success" {
t.Fatalf("result = %v", parsed["result"])
}
}
func TestUsageToResponses(t *testing.T) {
usage := map[string]any{
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"prompt_tokens_details": map[string]any{"cached_tokens": 4},
"completion_tokens_details": map[string]any{"reasoning_tokens": 5},
}
result := UsageToResponses(usage)
m, ok := result.(map[string]any)
if !ok {
t.Fatalf("result type = %T", result)
}
if m["input_tokens"] != float64(10) {
t.Fatalf("input_tokens = %v", m["input_tokens"])
}
if m["output_tokens"] != float64(20) {
t.Fatalf("output_tokens = %v", m["output_tokens"])
}
if m["total_tokens"] != float64(30) {
t.Fatalf("total_tokens = %v", m["total_tokens"])
}
if d, ok := m["input_tokens_details"].(map[string]any); !ok || d["cached_tokens"] != float64(4) {
t.Fatalf("input_tokens_details = %v", m["input_tokens_details"])
}
if d, ok := m["output_tokens_details"].(map[string]any); !ok || d["reasoning_tokens"] != float64(5) {
t.Fatalf("output_tokens_details = %v", m["output_tokens_details"])
}
}
func TestParseResponsesRequest_TextFormat(t *testing.T) {
body := `{"model":"GLM-4.7","input":"Jane, 54","text":{"format":{"type":"json_schema","name":"person","strict":true,"schema":{"type":"object"}}}}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
rf, ok := req.Extra["response_format"]
if !ok {
t.Fatal("response_format not in Extra")
}
var m map[string]any
if err := json.Unmarshal(rf, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "json_schema" {
t.Fatalf("type = %v", m["type"])
}
js, ok := m["json_schema"].(map[string]any)
if !ok {
t.Fatalf("json_schema = %v", m["json_schema"])
}
if js["name"] != "person" {
t.Fatalf("name = %v", js["name"])
}
if js["strict"] != true {
t.Fatalf("strict = %v", js["strict"])
}
}
func TestParseResponsesRequest_TextFormatJsonObject(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","text":{"format":{"type":"json_object"}}}`
req, err := ParseResponsesRequest([]byte(body))
if err != nil {
t.Fatal(err)
}
rf, ok := req.Extra["response_format"]
if !ok {
t.Fatal("response_format not in Extra")
}
var m map[string]any
if err := json.Unmarshal(rf, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "json_object" {
t.Fatalf("type = %v", m["type"])
}
}
func TestParseResponsesMeta_Defaults(t *testing.T) {
meta := ParseResponsesMeta([]byte(`{"model":"GLM-4.7","input":"hi"}`))
if meta.ParallelToolCalls != false {
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
}
if meta.ToolChoice != "auto" {
t.Fatalf("tool_choice = %v", meta.ToolChoice)
}
tools, ok := meta.Tools.([]any)
if !ok || len(tools) != 0 {
t.Fatalf("tools = %v", meta.Tools)
}
}
func TestParseResponsesMeta_EchoBack(t *testing.T) {
body := `{"model":"GLM-4.7","input":"hi","parallel_tool_calls":true,"tool_choice":"required","tools":[{"type":"function","name":"get_weather"}],"temperature":0.7,"max_output_tokens":500}`
meta := ParseResponsesMeta([]byte(body))
if meta.ParallelToolCalls != true {
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
}
if meta.ToolChoice != "required" {
t.Fatalf("tool_choice = %v", meta.ToolChoice)
}
tools, ok := meta.Tools.([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools = %v", meta.Tools)
}
if meta.Temperature != 0.7 {
t.Fatalf("temperature = %v", meta.Temperature)
}
if meta.MaxOutputTokens != float64(500) {
t.Fatalf("max_output_tokens = %v", meta.MaxOutputTokens)
}
}
+9 -1
View File
@@ -1,6 +1,9 @@
package openai
import "encoding/json"
import (
"encoding/json"
"strings"
)
type ChatCompletionRequest struct {
Model string `json:"model"`
@@ -40,6 +43,11 @@ func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
m[k] = anyValue
}
}
// Mirror the Zhanlu plugin: GLM models require {tool_stream:true} to
// stream tool calls back to the client (matches /glm/i.test(model)).
if strings.Contains(strings.ToLower(r.Model), "glm") {
m["tool_stream"] = true
}
return json.Marshal(m)
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"bytes"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
maxRecentEntries = 10
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
)
// RecentEntry captures a single API request and its response for debugging.
type RecentEntry struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
DurationMs int64 `json:"duration_ms"`
RequestHeaders map[string]string `json:"request_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
}
// RecentRecorder is an in-memory ring buffer that stores the most recent API
// requests. It is safe for concurrent use.
type RecentRecorder struct {
mu sync.Mutex
entries []RecentEntry
}
func NewRecentRecorder() *RecentRecorder {
return &RecentRecorder{}
}
// Record appends an entry, evicting the oldest when the buffer is full.
func (r *RecentRecorder) Record(e RecentEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
if len(r.entries) > maxRecentEntries {
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
}
}
// Entries returns a copy of the buffer in newest-first order.
func (r *RecentRecorder) Entries() []RecentEntry {
r.mu.Lock()
defer r.mu.Unlock()
n := len(r.entries)
out := make([]RecentEntry, n)
for i, e := range r.entries {
out[n-1-i] = e // reverse: newest first
}
return out
}
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
// and a truncated copy of the response body. It implements http.Flusher so
// streaming handlers can flush through the wrapper.
type recordingResponseWriter struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
totalBytes int
}
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (w *recordingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
w.totalBytes += len(b)
if w.body.Len() < maxBodyCapture {
remaining := maxBodyCapture - w.body.Len()
if len(b) <= remaining {
w.body.Write(b)
} else {
w.body.Write(b[:remaining])
}
}
return w.ResponseWriter.Write(b)
}
func (w *recordingResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// captureRequestHeaders extracts selected request headers, masking the
// Authorization value to avoid leaking API keys.
func captureRequestHeaders(r *http.Request) map[string]string {
headers := map[string]string{}
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
if v := r.Header.Get(key); v != "" {
if key == "Authorization" {
headers[key] = mask(v)
} else {
headers[key] = v
}
}
}
return headers
}
// truncateBody returns the string form of b, truncated to maxBodyCapture
// bytes with a marker if the original was longer.
func truncateBody(b []byte) string {
if len(b) > maxBodyCapture {
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// formatResponseBody returns the captured response body, with a truncation
// marker if the full response exceeded the capture limit.
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
s := buf.String()
if totalBytes > maxBodyCapture {
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
}
return s
}
// withRecording wraps a handler so that each request's headers, body,
// response status, and response body (truncated) are captured into the
// server's RecentRecorder. It is applied to the /v1/ API routes so that
// both successful and error responses are recorded.
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Read and restore the request body so the downstream handler
// still sees the full content.
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
recW := newRecordingResponseWriter(w)
next(recW, r)
s.recorder.Record(RecentEntry{
Timestamp: start,
Method: r.Method,
Path: r.URL.Path,
Status: recW.statusCode,
DurationMs: time.Since(start).Milliseconds(),
RequestHeaders: captureRequestHeaders(r),
RequestBody: truncateBody(reqBody),
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
})
}
}
// getRecent handles GET /api/recent — returns the most recent API requests
// as JSON, newest first.
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"requests": s.recorder.Entries(),
})
}
+148
View File
@@ -0,0 +1,148 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
// maxRecentEntries and evicts the oldest when full.
func TestRecentRecorderRingBuffer(t *testing.T) {
rec := NewRecentRecorder()
for i := 0; i < maxRecentEntries+5; i++ {
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
}
entries := rec.Entries()
if len(entries) != maxRecentEntries {
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
}
}
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
func TestRecentRecorderNewestFirst(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Path: "/first"})
rec.Record(RecentEntry{Path: "/second"})
rec.Record(RecentEntry{Path: "/third"})
entries := rec.Entries()
if len(entries) != 3 {
t.Fatalf("got %d entries", len(entries))
}
if entries[0].Path != "/third" {
t.Fatalf("first entry = %q, want /third", entries[0].Path)
}
if entries[2].Path != "/first" {
t.Fatalf("last entry = %q, want /first", entries[2].Path)
}
}
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
// request headers, request body, response status, and response body.
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":"ok"}`))
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
req.Header.Set("User-Agent", "test-client/1.0")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Method != "POST" {
t.Fatalf("method = %q", e.Method)
}
if e.Path != "/v1/chat/completions" {
t.Fatalf("path = %q", e.Path)
}
if e.Status != 200 {
t.Fatalf("status = %d", e.Status)
}
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
t.Fatalf("request body = %q", e.RequestBody)
}
if e.ResponseBody != `{"result":"ok"}` {
t.Fatalf("response body = %q", e.ResponseBody)
}
// Authorization must be masked
auth, ok := e.RequestHeaders["Authorization"]
if !ok || !strings.Contains(auth, "****") {
t.Fatalf("authorization not masked: %q", auth)
}
if strings.Contains(auth, "sk-secret-key-12345") {
t.Fatal("authorization leaked raw key")
}
if e.RequestHeaders["Content-Type"] != "application/json" {
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
}
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
}
}
// TestWithRecordingCapturesErrors verifies error responses are recorded.
func TestWithRecordingCapturesErrors(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
req.Header.Set("Authorization", "Bearer wrong-key")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Status != 401 {
t.Fatalf("status = %d, want 401", e.Status)
}
if !strings.Contains(e.ResponseBody, "invalid api key") {
t.Fatalf("response body = %q", e.ResponseBody)
}
}
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
func TestGetRecentAPI(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
s := &Server{recorder: rec}
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
w := httptest.NewRecorder()
s.getRecent(w, req)
body, _ := io.ReadAll(w.Body)
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("response missing ok:true: %s", string(body))
}
if !strings.Contains(string(body), "/v1/responses") {
t.Fatalf("response missing /v1/responses: %s", string(body))
}
// newest first
idxResponses := strings.Index(string(body), "/v1/responses")
idxChat := strings.Index(string(body), "/v1/chat/completions")
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
t.Fatalf("entries not newest-first: %s", string(body))
}
}
+563
View File
@@ -0,0 +1,563 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
)
// responses handles POST /v1/responses — the OpenAI Responses API. It
// translates the request to a chat-completions request for the upstream
// gateway, then translates the response back into the Responses format.
func (s *Server) responses(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
chatReq, err := openai.ParseResponsesRequest(body)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
return
}
meta := openai.ParseResponsesMeta(body)
if chatReq.Model == "" {
chatReq.Model = "zhanlu/auto"
}
start := time.Now()
clientWantsStream := chatReq.Stream
chatReq.Stream = true // upstream always streams
upstreamBody, err := chatReq.MarshalForUpstream()
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
resp, ok := s.callUpstream(w, r, creds, chatReq.Model, upstreamBody, clientWantsStream, start)
if !ok {
return
}
defer resp.Body.Close()
if clientWantsStream {
usage, status := s.proxyResponsesStream(w, resp, chatReq.Model, meta)
s.record(chatReq.Model, true, usage, status, start)
return
}
usage, status := s.aggregateResponsesStream(w, resp, chatReq.Model, meta)
s.record(chatReq.Model, false, usage, status, start)
}
// ---------------------------------------------------------------- non-streaming
// aggregateResponsesStream reads the upstream SSE, aggregates it (same logic
// as aggregateStream), then emits a Responses API JSON object.
func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) {
var content, reasoning, id string
var usage any
finishReason := "stop"
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event chatStreamChunk
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
if event.ID != "" {
id = event.ID
}
if event.Usage != nil {
usage = event.Usage
}
if len(event.Choices) > 0 {
content += event.Choices[0].Delta.Content
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
for _, part := range event.Choices[0].Delta.ToolCalls {
call := toolCalls[part.Index]
if call == nil {
call = &toolCall{Type: "function"}
toolCalls[part.Index] = call
}
if part.ID != "" {
call.ID = part.ID
}
if part.Type != "" {
call.Type = part.Type
}
call.Function.Name += part.Function.Name
call.Function.Arguments += part.Function.Arguments
}
if event.Choices[0].FinishReason != nil {
finishReason = *event.Choices[0].FinishReason
}
}
return nil
})
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
return nil, "upstream_error"
}
_ = id
// Map the upstream finish_reason onto the Responses API status: a
// length/content_filter cutoff means the response is incomplete, not
// completed. Default ("stop"/"tool_calls") stays "completed".
status := "completed"
switch finishReason {
case "length", "content_filter":
status = "incomplete"
}
respID := "resp_" + randomRequestID()
output := []any{}
if reasoning != "" {
output = append(output, map[string]any{
"type": "reasoning", "id": "rs_" + randomRequestID(), "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": reasoning}},
})
}
// Message output item — included when there is text content.
if content != "" {
output = append(output, map[string]any{
"type": "message", "id": "msg_" + randomRequestID(), "status": "completed",
"role": "assistant",
"content": []map[string]any{{
"type": "output_text", "text": content,
"annotations": []any{}, "logprobs": []any{},
}},
})
}
if len(toolCalls) > 0 {
indices := make([]int, 0, len(toolCalls))
for i := range toolCalls {
indices = append(indices, i)
}
sort.Ints(indices)
ordered := make([]*toolCall, 0, len(toolCalls))
for _, i := range indices {
ordered = append(ordered, toolCalls[i])
}
for _, tc := range ordered {
output = append(output, map[string]any{
"type": "function_call", "id": "fc_" + randomRequestID(),
"call_id": tc.ID, "name": tc.Function.Name,
"arguments": tc.Function.Arguments, "status": "completed",
})
}
}
// If there was no content and no tool calls, still emit an empty message
// so the response always has at least one output item.
if len(output) == 0 {
output = append(output, map[string]any{
"type": "message", "id": "msg_" + randomRequestID(), "status": "completed",
"role": "assistant", "content": []map[string]any{},
})
}
result := map[string]any{
"id": respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": status, "output": output,
"parallel_tool_calls": meta.ParallelToolCalls,
"tool_choice": meta.ToolChoice,
"tools": meta.Tools,
}
if usage != nil {
result["usage"] = openai.UsageToResponses(usage)
}
writeJSON(w, http.StatusOK, result)
return usage, "success"
}
// ------------------------------------------------------------------- streaming
// responsesStreamState tracks the lifecycle of output items while converting
// Chat Completions SSE into Responses API SSE events.
type responsesStreamState struct {
w http.ResponseWriter
flusher http.Flusher
respID string
msgID string
rsID string
meta openai.ResponsesMeta
outputIdx int
reasoningOn bool
reasonPartOn bool
messageOn bool
partOn bool
fullContent string
fullReasoning string
usage any
status string
finishReason string
// tool call tracking
tools map[int]*streamToolCall
toolOrder []int
}
type streamToolCall struct {
itemID string
callID string
name string
args string
started bool
doneIdx int // assigned output index when started
}
func newResponsesStreamState(w http.ResponseWriter, model string, meta openai.ResponsesMeta) *responsesStreamState {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
st := &responsesStreamState{
w: w,
flusher: flusher,
respID: "resp_" + randomRequestID(),
msgID: "msg_" + randomRequestID(),
rsID: "rs_" + randomRequestID(),
meta: meta,
tools: map[int]*streamToolCall{},
status: "success",
}
baseResp := func(st2 string) map[string]any {
return map[string]any{
"id": st.respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": st2, "output": []any{},
"parallel_tool_calls": meta.ParallelToolCalls,
"tool_choice": meta.ToolChoice,
"tools": meta.Tools,
}
}
st.emit("response.created", map[string]any{"type": "response.created", "response": baseResp("in_progress")})
st.emit("response.in_progress", map[string]any{"type": "response.in_progress", "response": baseResp("in_progress")})
return st
}
func (st *responsesStreamState) emit(event string, data any) {
b, _ := json.Marshal(data)
fmt.Fprintf(st.w, "event: %s\ndata: %s\n\n", event, b)
if st.flusher != nil {
st.flusher.Flush()
}
}
// closeReasoning closes the reasoning output item if it is open.
func (st *responsesStreamState) closeReasoning() {
if !st.reasoningOn {
return
}
if st.reasonPartOn {
st.emit("response.reasoning_summary_text.done", map[string]any{
"type": "response.reasoning_summary_text.done", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0, "text": st.fullReasoning,
})
st.emit("response.reasoning_summary_part.done", map[string]any{
"type": "response.reasoning_summary_part.done", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0,
"part": map[string]any{"type": "summary_text", "text": st.fullReasoning},
})
st.reasonPartOn = false
}
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": st.outputIdx,
"item": map[string]any{
"type": "reasoning", "id": st.rsID, "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}},
},
})
st.reasoningOn = false
st.outputIdx++
}
// closeMessage closes the message output item if it is open.
func (st *responsesStreamState) closeMessage() {
if !st.messageOn {
return
}
if st.partOn {
st.emit("response.output_text.done", map[string]any{
"type": "response.output_text.done", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0, "text": st.fullContent,
})
st.emit("response.content_part.done", map[string]any{
"type": "response.content_part.done", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}},
})
st.partOn = false
}
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": st.outputIdx,
"item": map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}},
},
})
st.messageOn = false
st.outputIdx++
}
// handleReasoning processes a reasoning content delta.
func (st *responsesStreamState) handleReasoning(delta string) {
if !st.reasoningOn {
st.reasoningOn = true
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "reasoning", "id": st.rsID, "status": "in_progress",
"content": []any{}, "summary": []any{},
},
})
st.emit("response.reasoning_summary_part.added", map[string]any{
"type": "response.reasoning_summary_part.added", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0,
"part": map[string]any{"type": "summary_text", "text": ""},
})
st.reasonPartOn = true
}
st.fullReasoning += delta
st.emit("response.reasoning_summary_text.delta", map[string]any{
"type": "response.reasoning_summary_text.delta", "item_id": st.rsID,
"output_index": st.outputIdx, "summary_index": 0, "delta": delta,
})
}
// handleContent processes a text content delta.
func (st *responsesStreamState) handleContent(delta string) {
// Close reasoning if open — text content comes after reasoning.
st.closeReasoning()
if !st.messageOn {
st.messageOn = true
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "message", "id": st.msgID, "status": "in_progress", "role": "assistant", "content": []any{},
},
})
st.emit("response.content_part.added", map[string]any{
"type": "response.content_part.added", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0,
"part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}},
})
st.partOn = true
}
st.fullContent += delta
st.emit("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "item_id": st.msgID,
"output_index": st.outputIdx, "content_index": 0, "delta": delta,
})
}
// handleToolCall processes a tool call delta from the chat completion stream.
func (st *responsesStreamState) handleToolCall(index int, id, name, args string) {
// Close reasoning/message if open — tool calls are separate output items.
st.closeReasoning()
st.closeMessage()
call := st.tools[index]
if call == nil {
call = &streamToolCall{itemID: "fc_" + randomRequestID()}
st.tools[index] = call
st.toolOrder = append(st.toolOrder, index)
}
if id != "" {
call.callID = id
}
if name != "" {
call.name += name
}
if !call.started {
call.started = true
call.doneIdx = st.outputIdx
st.emit("response.output_item.added", map[string]any{
"type": "response.output_item.added", "output_index": st.outputIdx,
"item": map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": "", "status": "in_progress",
},
})
st.outputIdx++
}
if args != "" {
call.args += args
st.emit("response.function_call_arguments.delta", map[string]any{
"type": "response.function_call_arguments.delta", "item_id": call.itemID,
"output_index": call.doneIdx, "delta": args,
})
}
}
// finish closes all open items and sends the response.completed event.
func (st *responsesStreamState) finish(model string) {
st.closeReasoning()
st.closeMessage()
for _, idx := range st.toolOrder {
call := st.tools[idx]
st.emit("response.function_call_arguments.done", map[string]any{
"type": "response.function_call_arguments.done", "item_id": call.itemID,
"output_index": call.doneIdx, "arguments": call.args,
})
st.emit("response.output_item.done", map[string]any{
"type": "response.output_item.done", "output_index": call.doneIdx,
"item": map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": call.args, "status": "completed",
},
})
}
// Build the final output array for the completed event.
finalOutput := []any{}
if st.fullReasoning != "" {
finalOutput = append(finalOutput, map[string]any{
"type": "reasoning", "id": st.rsID, "status": "completed",
"content": []any{},
"summary": []map[string]any{{"type": "summary_text", "text": st.fullReasoning}},
})
}
if st.fullContent != "" {
finalOutput = append(finalOutput, map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{{"type": "output_text", "text": st.fullContent, "annotations": []any{}, "logprobs": []any{}}},
})
}
for _, idx := range st.toolOrder {
call := st.tools[idx]
finalOutput = append(finalOutput, map[string]any{
"type": "function_call", "id": call.itemID, "call_id": call.callID,
"name": call.name, "arguments": call.args, "status": "completed",
})
}
// Ensure at least one output item exists.
if len(finalOutput) == 0 {
finalOutput = append(finalOutput, map[string]any{
"type": "message", "id": st.msgID, "status": "completed", "role": "assistant",
"content": []map[string]any{},
})
}
// Map the upstream finish_reason onto the Responses API status, mirroring
// the non-streaming path: a length/content_filter cutoff is "incomplete".
respStatus := "completed"
switch st.finishReason {
case "length", "content_filter":
respStatus = "incomplete"
}
completedResp := map[string]any{
"id": st.respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": respStatus, "output": finalOutput,
"parallel_tool_calls": st.meta.ParallelToolCalls,
"tool_choice": st.meta.ToolChoice,
"tools": st.meta.Tools,
}
if st.usage != nil {
completedResp["usage"] = openai.UsageToResponses(st.usage)
}
st.emit("response.completed", map[string]any{"type": "response.completed", "response": completedResp})
}
// proxyResponsesStream reads the upstream Chat Completions SSE and emits
// Responses API SSE events via a responsesStreamState state machine.
func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response, model string, meta openai.ResponsesMeta) (any, string) {
st := newResponsesStreamState(w, model, meta)
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if line != "" {
payload := sseDataPayload(line)
if payload != "" && payload != "[DONE]" {
var evt struct {
State string `json:"state"`
ErrorMessage string `json:"errorMessage"`
Usage any `json:"usage"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if jErr := json.Unmarshal([]byte(payload), &evt); jErr == nil {
if evt.State == "ERROR" {
st.status = "upstream_error"
}
if evt.Usage != nil {
st.usage = evt.Usage
}
if len(evt.Choices) > 0 {
delta := evt.Choices[0].Delta
if evt.Choices[0].FinishReason != nil {
st.finishReason = *evt.Choices[0].FinishReason
}
reasoningDelta := delta.ReasoningContent + delta.Reasoning
if reasoningDelta != "" {
st.handleReasoning(reasoningDelta)
}
if delta.Content != "" {
st.handleContent(delta.Content)
}
for _, tc := range delta.ToolCalls {
st.handleToolCall(tc.Index, tc.ID, tc.Function.Name, tc.Function.Arguments)
}
}
}
}
}
if err != nil {
if err != io.EOF {
b, _ := json.Marshal(map[string]any{
"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error",
})
fmt.Fprintf(w, "data: %s\n\n", b)
if st.flusher != nil {
st.flusher.Flush()
}
st.status = "upstream_error"
}
break
}
}
st.finish(model)
return st.usage, st.status
}
+304
View File
@@ -0,0 +1,304 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
// TestResponsesNonStreaming verifies POST /v1/responses with stream:false
// returns a properly formatted Responses API JSON object.
func TestResponsesNonStreaming(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
body := `{"model":"GLM-4.7","input":"hi","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if result["object"] != "response" {
t.Fatalf("object = %v", result["object"])
}
if result["status"] != "completed" {
t.Fatalf("status = %v", result["status"])
}
output, ok := result["output"].([]any)
if !ok || len(output) == 0 {
t.Fatalf("output = %v", result["output"])
}
msg, ok := output[0].(map[string]any)
if !ok {
t.Fatalf("output[0] type = %T", output[0])
}
if msg["type"] != "message" {
t.Fatalf("output[0] type = %v", msg["type"])
}
if msg["role"] != "assistant" {
t.Fatalf("output[0] role = %v", msg["role"])
}
content, ok := msg["content"].([]any)
if !ok || len(content) == 0 {
t.Fatalf("content = %v", msg["content"])
}
part, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] type = %T", content[0])
}
if part["type"] != "output_text" {
t.Fatalf("content[0] type = %v", part["type"])
}
if part["text"] != "hello" {
t.Fatalf("content[0] text = %v", part["text"])
}
// Verify required SDK fields are present.
if _, ok := result["parallel_tool_calls"]; !ok {
t.Fatal("missing parallel_tool_calls in response")
}
if _, ok := result["tool_choice"]; !ok {
t.Fatal("missing tool_choice in response")
}
if _, ok := result["tools"]; !ok {
t.Fatal("missing tools in response")
}
// Verify output_text has logprobs field.
if _, ok := part["logprobs"]; !ok {
t.Fatal("missing logprobs in output_text content part")
}
usage, ok := result["usage"].(map[string]any)
if !ok {
t.Fatalf("usage = %v", result["usage"])
}
if totNum(usage["input_tokens"]) != 10 {
t.Fatalf("input_tokens = %v", usage["input_tokens"])
}
if totNum(usage["output_tokens"]) != 20 {
t.Fatalf("output_tokens = %v", usage["output_tokens"])
}
if totNum(usage["total_tokens"]) != 30 {
t.Fatalf("total_tokens = %v", usage["total_tokens"])
}
}
// TestResponsesStreaming verifies POST /v1/responses with stream:true
// emits proper Responses API SSE events.
func TestResponsesStreaming(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
body := `{"model":"GLM-4.7","input":"hi","stream":true}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
// Check key lifecycle events are present.
checks := []string{
"event: response.created",
"event: response.in_progress",
"event: response.output_item.added",
"event: response.content_part.added",
"event: response.output_text.delta",
"event: response.output_text.done",
"event: response.content_part.done",
"event: response.output_item.done",
"event: response.completed",
}
for _, c := range checks {
if !strings.Contains(s, c) {
t.Fatalf("missing %q in SSE body:\n%s", c, s)
}
}
// Verify the text delta contains "hello".
if !strings.Contains(s, `"delta":"hello"`) {
t.Fatalf("text delta missing 'hello' in SSE body:\n%s", s)
}
// Verify required SDK fields are present in completed event.
if !strings.Contains(s, `"parallel_tool_calls"`) {
t.Fatalf("missing parallel_tool_calls in SSE body:\n%s", s)
}
if !strings.Contains(s, `"tool_choice"`) {
t.Fatalf("missing tool_choice in SSE body:\n%s", s)
}
// Verify logprobs field in output_text.
if !strings.Contains(s, `"logprobs"`) {
t.Fatalf("missing logprobs in SSE body:\n%s", s)
}
// Verify usage in the completed event has input_tokens.
if !strings.Contains(s, `"input_tokens":`) {
t.Fatalf("usage missing input_tokens in SSE body:\n%s", s)
}
}
// TestResponsesStringInput verifies the proxy correctly translates a string
// "input" to a chat-completions "messages" array before forwarding upstream.
func TestResponsesStringInput(t *testing.T) {
var capturedBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/chat/completions" {
capturedBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
return
}
http.NotFound(w, r)
}))
defer upstream.Close()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := config.Config{
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
defer proxy.Close()
creds := auth.Credentials{
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
_ = st.SaveCredentials(creds)
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if len(capturedBody) == 0 {
t.Fatal("no upstream request body captured")
}
var upstreamReq map[string]any
if err := json.Unmarshal(capturedBody, &upstreamReq); err != nil {
t.Fatal(err)
}
messages, ok := upstreamReq["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %v", upstreamReq["messages"])
}
msg, _ := messages[0].(map[string]any)
if msg["role"] != "user" || msg["content"] != "hello world" {
t.Fatalf("upstream message = %v", msg)
}
}
// TestResponsesInstructions verifies instructions are prepended as a system
// message in the upstream request.
func TestResponsesInstructions(t *testing.T) {
var capturedBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/chat/completions" {
capturedBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
return
}
http.NotFound(w, r)
}))
defer upstream.Close()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := config.Config{
MobileLoginBaseURL: upstream.URL,
MobileModelBaseURL: upstream.URL,
UpstreamPath: "/chat/completions",
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
PublicKeyPEM: defaultTestPublicKey,
PhonePublicKeyPEM: defaultTestPublicKey,
SM2PrivateKey: testSM2Key,
PluginVersion: "1.4.2",
}
h := New(cfg, st)
proxy := httptest.NewServer(h)
defer proxy.Close()
creds := auth.Credentials{
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
_ = st.SaveCredentials(creds)
body := `{"model":"GLM-4.7","instructions":"be concise","input":"hello","stream":false}`
resp, err := http.Post(proxy.URL+"/v1/responses", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if len(capturedBody) == 0 {
t.Fatal("no upstream request body captured")
}
var upstreamReq map[string]any
_ = json.Unmarshal(capturedBody, &upstreamReq)
messages, _ := upstreamReq["messages"].([]any)
if len(messages) != 2 {
t.Fatalf("messages = %d items", len(messages))
}
sys, _ := messages[0].(map[string]any)
if sys["role"] != "system" || sys["content"] != "be concise" {
t.Fatalf("system message = %v", sys)
}
}
+153 -677
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -15,6 +15,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
)
@@ -365,6 +366,47 @@ func TestModelTestNoCredentials(t *testing.T) {
}
}
// TestAdminRendersModelRangeFilter verifies the admin page renders without
// panic for both empty and populated summaries, and that the by-model panel
// carries the 1d/7d/all range filter controls and a client-renderable tbody.
func TestAdminRendersModelRangeFilter(t *testing.T) {
populated := &stats.Summary{
PerModel: []stats.ModelStat{
{Model: "GLM-4.7", Requests: 3, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4},
},
}
cases := []struct {
name string
summary *stats.Summary
}{
{"empty", &stats.Summary{}},
{"populated", populated},
}
for _, tc := range cases {
var buf bytes.Buffer
if err := adminTemplate.Execute(&buf, map[string]any{
"Enabled": true,
"Stats": tc.summary,
"DBPath": "zhanlu.db",
"PasswordEnabled": false,
}); err != nil {
t.Fatalf("%s: render admin: %v", tc.name, err)
}
body := buf.String()
for _, want := range []string{
`id="model-range"`,
`data-range="1d"`,
`data-range="7d"`,
`data-range="all"`,
`id="model-tbody"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("%s: admin output missing %q", tc.name, want)
}
}
}
}
// totNum extracts an int from a JSON-decoded numeric value (float64).
func totNum(v any) int {
f, _ := v.(float64)
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"embed"
"fmt"
"html/template"
)
// templateFS holds the rendered admin/login HTML pages. Keeping them as
// separate files (rather than inline raw-string literals in server.go) gives
// them real syntax highlighting and keeps server.go focused on handlers.
//
//go:embed templates/*.html
var templateFS embed.FS
var (
loginTemplate = mustParseTemplate("login.html", "login")
loginResultTemplate = mustParseTemplate("login_result.html", "login-result")
adminTemplate = mustParseTemplateFuncs("admin.html", "admin", template.FuncMap{
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
"rate": func(cached, prompt int64) string {
if prompt <= 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
},
"human": humanNum,
})
)
// mustParseTemplate reads a single embedded template file and parses it,
// panicking on error (a malformed template is a build-time mistake).
func mustParseTemplate(filename, name string) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Parse(string(data)))
}
// mustParseTemplateFuncs is mustParseTemplate with a FuncMap registered before
// parsing, so the template body may reference the custom functions.
func mustParseTemplateFuncs(filename, name string, funcs template.FuncMap) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Funcs(funcs).Parse(string(data)))
}
+609
View File
@@ -0,0 +1,609 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理管理后台</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.btn-ghost:active{transform:translateY(1px)}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
.tab:hover{color:var(--ink)}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.tabpanel{display:none;min-width:0}
.tabpanel.active{display:block;min-width:0}
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.panel-head h2{margin:0}
.seg{display:inline-flex;border:1px solid var(--border-strong);border-radius:999px;padding:2px;background:var(--bg)}
.seg-btn{border:0;background:none;font:inherit;font-weight:600;font-size:12.5px;color:var(--body);padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease}
.seg-btn:hover{color:var(--ink)}
.seg-btn[aria-current="true"]{background:var(--accent);color:#fff}
.seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
.panel.disabled{background:#eef0f3}
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody tr:last-child td{border-bottom:0}
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.muted{color:var(--muted);font-size:13px;line-height:1.6}
.scroll{overflow-x:auto;min-width:0}
.empty{color:var(--muted);font-size:13px;padding:8px 0}
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.page-btn:disabled{opacity:.4;cursor:not-allowed}
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
.btn-primary.full{width:100%}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
.recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)}
.recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
.recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px}
.recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-card details{margin-top:6px}
.recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none}
.recent-card summary:hover{color:var(--accent)}
.recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
<h1>湛卢代理管理</h1>
</div>
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
</header>
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="recent" aria-selected="false">最近请求</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div>
<section class="tabpanel active" data-tab="stats" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="refresh">刷新</button>
<button class="btn btn-primary" id="reset">重置统计</button>
</div>
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
<div class="panel">
<h2>总览</h2>
<div class="grid4">
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
</div>
</div>
<div class="panel" style="margin-top:20px">
<div class="panel-head">
<h2>按模型</h2>
<div class="seg" id="model-range" role="group" aria-label="按模型统计时间范围">
<button class="seg-btn" type="button" data-range="1d">1 天</button>
<button class="seg-btn" type="button" data-range="7d">7 天</button>
<button class="seg-btn" type="button" data-range="all" aria-current="true">全部</button>
</div>
</div>
<div class="scroll" id="model-scroll"><table>
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
<tbody id="model-tbody">
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
</tbody>
</table></div>
<p class="empty" id="model-empty" style="display:none">暂无数据</p>
</div>
<div class="panel" style="margin-top:20px">
<h2>按日</h2>
{{if .Stats.Daily}}
<div id="daily">
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
</div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>最近请求</h2>
{{if .Stats.Recent}}
<div class="scroll"><table id="recent">
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
<tbody>
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
</tbody>
</table></div>
<div class="pager" id="recent-pager"></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
</section>
<section class="tabpanel" data-tab="models" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
</div>
<div class="panel">
<h2>可用模型</h2>
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
<div class="scroll">
<table id="models-table">
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="tabpanel" data-tab="recent" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
</div>
<div class="panel">
<h2>最近 API 请求</h2>
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
<div id="recent-list"></div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
</div>
</section>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tabpanel');
function activate(name) {
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.dataset.tab;
activate(name);
if (history.replaceState) history.replaceState(null, '', '#' + name);
});
});
var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models');
else if (hash === 'recent') activate('recent');
else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar');
var max = 1;
for (var i = 0; i < rows.length; i++) {
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
if (t > max) max = t;
}
for (var j = 0; j < rows.length; j++) {
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
}
var refresh = document.getElementById('refresh');
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
var reset = document.getElementById('reset');
if (reset) reset.addEventListener('click', function () {
if (!confirm('确定清空所有统计数据?')) return;
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
});
// by-model time-range filter (1d / 7d / all) — fetches /api/stats with a
// since cutoff and re-renders only the per-model table client-side.
(function () {
var seg = document.getElementById('model-range');
var tbody = document.getElementById('model-tbody');
var scroll = document.getElementById('model-scroll');
var empty = document.getElementById('model-empty');
if (!seg || !tbody) return;
function trimZero(s) { return s.indexOf('.') >= 0 ? s.replace(/\.0$/, '') : s; }
function human(v) {
var f = Number(v) || 0;
if (f < 1000) return String(Math.round(f));
if (f < 1e6) return trimZero((f / 1e3).toFixed(1)) + 'K';
if (f < 1e9) return trimZero((f / 1e6).toFixed(1)) + 'M';
return trimZero((f / 1e9).toFixed(1)) + 'B';
}
function rate(cached, prompt) {
if (prompt <= 0) return '0%';
return (cached / prompt * 100).toFixed(1) + '%';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function showEmpty(yes) {
scroll.style.display = yes ? 'none' : '';
empty.style.display = yes ? '' : 'none';
}
function render(rows) {
if (!rows || rows.length === 0) { tbody.innerHTML = ''; showEmpty(true); return; }
showEmpty(false);
var html = '';
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
html += '<tr><td>' + escapeHtml(r.model) + '</td>' +
'<td class="num">' + (r.requests || 0) + '</td>' +
'<td class="num">' + human(r.prompt_tokens) + '</td>' +
'<td class="num">' + human(r.completion_tokens) + '</td>' +
'<td class="num">' + human(r.total_tokens) + '</td>' +
'<td class="num">' + human(r.cached_tokens) + '</td>' +
'<td class="num">' + rate(r.cached_tokens, r.prompt_tokens) + '</td></tr>';
}
tbody.innerHTML = html;
}
// initial empty check (server-rendered "all" may have no rows)
if (!tbody.querySelector('tr')) showEmpty(true);
function sinceParam(range) {
if (range === 'all') return '';
var days = range === '1d' ? 1 : 7;
return 'since=' + encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString());
}
seg.addEventListener('click', async function (ev) {
var btn = ev.target.closest('.seg-btn');
if (!btn) return;
seg.querySelectorAll('.seg-btn').forEach(function (b) {
b.setAttribute('aria-current', b === btn ? 'true' : 'false');
});
var range = btn.dataset.range;
var param = sinceParam(range);
var url = '/api/stats' + (param ? '?' + param : '');
showEmpty(false);
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载中…</td></tr>';
try {
var res = await fetch(url);
var data = await res.json();
if (!data.stats) { render([]); return; }
render(data.stats.per_model || []);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center">加载失败</td></tr>';
}
});
})();
// paginate the recent-requests table (data is already rendered server-side)
(function () {
var tbody = document.querySelector('#recent tbody');
var pager = document.getElementById('recent-pager');
if (!tbody || !pager) return;
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) { pager.style.display = 'none'; return; }
var pageSize = 10;
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
var page = 1;
function pageList(c, t) {
var p = [];
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
p.push(1);
if (c > 3) p.push('…');
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
for (var m = s; m <= e; m++) p.push(m);
if (c < t - 2) p.push('…');
p.push(t);
return p;
}
function render() {
var start = (page - 1) * pageSize;
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
}
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '></button>';
var list = pageList(page, totalPages);
for (var n = 0; n < list.length; n++) {
var item = list[n];
if (item === '…') html += '<span class="page-btn dots">…</span>';
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
}
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '></button>';
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
pager.innerHTML = html;
}
pager.addEventListener('click', function (ev) {
var btn = ev.target.closest('.page-btn');
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
if (btn.dataset.act === 'prev' && page > 1) page--;
else if (btn.dataset.act === 'next' && page < totalPages) page++;
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
render();
});
render();
})();
var logout = document.getElementById('logout-button');
if (logout) logout.addEventListener('click', async function () {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
})();
(function () {
var statusEl = document.getElementById('status');
var form = document.getElementById('phone-form');
var codeButton = document.getElementById('code-button');
var secret = '';
var countdown = 0;
var countdownTimer = null;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (form) {
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
if (codeButton) codeButton.addEventListener('click', async function () {
var telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
var data = await res.json();
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
if (form) form.addEventListener('submit', async function (event) {
event.preventDefault();
var telephone = form.telephone.value.trim();
var code = form.code.value.trim();
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
setStatus('正在登录并保存凭据...', 'busy');
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
})();
(function () {
var refreshBtn = document.getElementById('models-refresh');
var testAllBtn = document.getElementById('models-test-all');
var statusEl = document.getElementById('models-status');
var tbody = document.querySelector('#models-table tbody');
if (!refreshBtn || !tbody) return;
var loaded = false;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function renderRow(model) {
var tr = document.createElement('tr');
tr.dataset.model = model;
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
'<td class="m-status"><span class="muted">未测试</span></td>' +
'<td class="num m-ttft">—</td>' +
'<td class="num m-total">—</td>' +
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
return tr;
}
async function loadModels() {
setStatus('正在获取模型列表...', 'busy');
refreshBtn.disabled = true;
try {
var res = await fetch('/api/models');
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
var models = data.models || [];
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
tbody.innerHTML = '';
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
} catch (e) {
setStatus('获取模型列表失败:' + e.message, 'err');
} finally {
refreshBtn.disabled = false;
}
}
async function testModel(model, row) {
var statusCell = row.querySelector('.m-status');
var ttftCell = row.querySelector('.m-ttft');
var totalCell = row.querySelector('.m-total');
var btn = row.querySelector('.m-test');
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
ttftCell.textContent = '—';
totalCell.textContent = '—';
btn.disabled = true;
try {
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
var data = await res.json();
if (data.available) {
statusCell.innerHTML = '<span class="badge ok">可用</span>';
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
} else {
statusCell.innerHTML = '<span class="badge err">不可用</span>';
statusCell.title = data.error || '';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
}
} catch (e) {
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
statusCell.title = e.message;
} finally {
btn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadModels);
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
await testModel(rows[i].dataset.model, rows[i]);
}
});
tbody.addEventListener('click', function (ev) {
var btn = ev.target.closest('.m-test');
if (!btn) return;
var row = btn.closest('tr');
if (row && row.dataset.model) testModel(row.dataset.model, row);
});
// lazy-load the model list the first time the tab becomes active
function loadIfActive() {
if (loaded) return;
var panel = document.querySelector('.tabpanel[data-tab="models"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
}
var modelsTab = document.querySelector('.tab[data-tab="models"]');
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
(function () {
var recentList = document.getElementById('recent-list');
if (!recentList) return;
var loaded = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function formatTime(ts) {
var d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
async function loadRecent() {
recentList.innerHTML = '<p class="muted">加载中…</p>';
try {
var res = await fetch('/api/recent');
var data = await res.json();
if (!data.ok || !data.requests || data.requests.length === 0) {
recentList.innerHTML = '<p class="empty">暂无数据</p>';
return;
}
var html = '';
for (var i = 0; i < data.requests.length; i++) {
var r = data.requests[i];
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
var headers = r.request_headers || {};
var headerStr = Object.keys(headers).map(function (k) {
return k + ': ' + headers[k];
}).join('\n');
html += '<div class="recent-card">' +
'<div class="recent-head">' +
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
'</div>' +
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
'</div>';
}
recentList.innerHTML = html;
} catch (e) {
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
}
}
var recentTab = document.querySelector('.tab[data-tab="recent"]');
if (recentTab) recentTab.addEventListener('click', function () {
if (!loaded) { loaded = true; loadRecent(); }
});
var refreshBtn = document.getElementById('recent-refresh');
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
// auto-load if the tab is active on page load
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
})();
</script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理登录</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:40px 36px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:0 0 10px;font-size:24px;font-weight:700;letter-spacing:-.02em}
.lede{margin:0 0 28px;color:var(--body);font-size:14px;line-height:1.6}
.lede code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--ink);background:var(--bg);padding:1px 6px;border-radius:5px;border:1px solid var(--border)}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:11px 18px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;text-decoration:none;color:#fff;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);width:100%}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-primary:disabled{background:#c2c9d4;cursor:not-allowed}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:24px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<main class="card">
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
<h1>湛卢代理登录</h1>
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入管理后台</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
</main>
<script>
const statusEl = document.getElementById('status');
const passwordForm = document.getElementById('password-form');
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) { setStatus('请输入登录密码', 'err'); return; }
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
window.location.href = '/admin';
});
}
</script>
</body>
</html>
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢登录结果</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:26px;height:26px}
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
.btn:hover{background:var(--accent-hover)}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
</head>
<body>
<main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/admin">返回管理后台</a>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// Package util holds small shared helpers used across internal packages to
// avoid divergent same-named copies.
package util
import (
"crypto/rand"
"fmt"
"strings"
"time"
)
// FirstNonEmpty returns the first trimmed-non-empty value, or "" when none of
// the values are non-empty. Callers that need a specific fallback for the
// all-empty case should apply it explicitly at the call site.
func FirstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// RandomRequestID returns a random v4 UUID string, matching the format the
// Zhanlu plugin sends in the `request` header of every gateway call
// (crypto.randomUUID()). Extracted so the login and phone-code paths share
// one implementation.
func RandomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
+5 -23
View File
@@ -13,6 +13,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Client struct {
@@ -93,8 +94,8 @@ func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := firstNonEmpty(organization, "未配置")
tm := firstNonEmpty(team, "未配置")
org := util.FirstNonEmpty(organization, "未配置")
tm := util.FirstNonEmpty(team, "未配置")
// Field order matters: the SM2 signature covers the exact JSON body bytes,
// matching the plugin's JSON.stringify({email, organization, team}).
body, err := json.Marshal(struct {
@@ -195,7 +196,7 @@ func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
seen := map[string]bool{}
models := make([]string, 0, len(out.Data))
for _, m := range out.Data {
id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
if id == "" || seen[id] {
continue
}
@@ -209,7 +210,7 @@ func (c *Client) setPluginHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("plugin_type", "zhanlu_ide")
req.Header.Set("plugin_version", c.PluginVersion)
req.Header.Set("request", randomRequestID())
req.Header.Set("request", util.RandomRequestID())
}
func decodeJSON(resp *http.Response, out any) error {
@@ -237,15 +238,6 @@ func findString(m map[string]any, keys ...string) string {
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
func randomAlnum(n int) string {
@@ -256,13 +248,3 @@ func randomAlnum(n int) string {
}
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:])
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# 实例测试流程固化脚本
#
# ./scripts/test-instance.sh start # 编译新二进制 + 以当前目录 zhanlu.db 启动
# ./scripts/test-instance.sh stop # 停止进程 + 清理 *-shm/*-wal
# ./scripts/test-instance.sh status # 查看运行状态
#
# 可用环境变量覆盖默认值:
# ZHANLU_DB_FILE 数据库路径 (默认 <项目根>/zhanlu.db)
# ZHANLU_LISTEN_ADDR 监听地址 (默认 127.0.0.1:8080)
set -euo pipefail
# 项目根目录(脚本位于 scripts/ 下)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKDIR="$ROOT/tmp" # 二进制/pid/log 都放这里(已 gitignore)
BIN="$WORKDIR/zhanlu-proxy-test"
PIDFILE="$WORKDIR/test-instance.pid"
LOGFILE="$WORKDIR/test-instance.log"
DB="${ZHANLU_DB_FILE:-$ROOT/zhanlu.db}"
ADDR="${ZHANLU_LISTEN_ADDR:-127.0.0.1:8080}"
start() {
mkdir -p "$WORKDIR"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "已有实例在运行 (pid $(cat "$PIDFILE")),请先执行: $0 stop" >&2
exit 1
fi
echo "==> 编译二进制..."
(cd "$ROOT" && go build -o "$BIN" ./cmd/zhanlu-proxy)
echo "==> 启动 (db=$DB addr=$ADDR)"
ZHANLU_DB_FILE="$DB" ZHANLU_LISTEN_ADDR="$ADDR" \
nohup "$BIN" > "$LOGFILE" 2>&1 &
echo $! > "$PIDFILE"
sleep 1
if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "启动失败,日志:" >&2
cat "$LOGFILE" >&2
rm -f "$PIDFILE"
exit 1
fi
port="${ADDR##*:}"
echo "==> 已启动 pid=$(cat "$PIDFILE")"
echo " 管理后台: http://127.0.0.1:$port/admin"
echo " 日志: $LOGFILE"
echo " 测试完成后执行: $0 stop"
}
stop() {
pid=""
[[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
# 优雅等待退出(给 SQLite checkpoint WAL 的时间)
for _ in $(seq 1 40); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.25
done
if kill -0 "$pid" 2>/dev/null; then
echo "==> 优雅退出超时,强制结束 pid=$pid"
kill -9 "$pid" 2>/dev/null || true
else
echo "==> 已停止进程 pid=$pid"
fi
else
echo "==> 无运行中的进程"
fi
rm -f "$PIDFILE"
# 清理 wal/shm(与 DB 同目录同名)
removed=0
for ext in db-shm db-wal; do
f="${DB%.*}.$ext"
if [[ -f "$f" ]]; then
rm -f "$f"
echo "==> 已清理 $f"
removed=1
fi
done
[[ $removed -eq 0 ]] && echo "==> 无 shm/wal 需清理"
rm -f "$BIN"
echo "==> 完成"
}
status() {
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "运行中 pid=$(cat "$PIDFILE") addr=$ADDR db=$DB"
else
echo "未运行"
fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) echo "用法: $0 {start|stop|status}" >&2; exit 1 ;;
esac