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

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

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

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

- recorder.go: RecentRecorder ring buffer, recordingResponseWriter,
  withRecording middleware, getRecent handler
- server.go: add recorder to Server, wrap /v1/ routes, add /api/recent
- admin.html: new 最近请求 tab with lazy-load and expandable cards
- recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
2026-08-23 14:52:33 +08:00
7 changed files with 1080 additions and 5 deletions
+1
View File
@@ -14,3 +14,4 @@ tmp/
temp/
source/
output/
error.md
+182 -1
View File
@@ -33,7 +33,7 @@ func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
m := map[string]any{
"model": r.Model,
"messages": r.Messages,
"messages": normalizeMessages(r.Messages, extractToolNames(r.Extra)),
"stream": r.Stream,
"stream_options": map[string]any{"include_usage": true},
}
@@ -51,6 +51,187 @@ func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
return json.Marshal(m)
}
// extractToolNames returns the set of function names declared in the tools
// array (Extra["tools"]), used to validate tool_calls in the message history.
func extractToolNames(extra map[string]json.RawMessage) map[string]bool {
names := map[string]bool{}
raw, ok := extra["tools"]
if !ok {
return names
}
var tools []any
if json.Unmarshal(raw, &tools) != nil {
return names
}
for _, t := range tools {
tm, ok := t.(map[string]any)
if !ok {
continue
}
// Chat Completions format: {type:"function",function:{name:"…"}}
if fn, ok := tm["function"].(map[string]any); ok {
if name, _ := fn["name"].(string); name != "" {
names[name] = true
}
}
// Responses API flat format: {type:"function",name:"…"}
if name, _ := tm["name"].(string); name != "" {
names[name] = true
}
}
return names
}
// normalizeMessages prepares the message history for the Zhanlu upstream
// gateway. It performs two normalizations:
//
// 1. Flattens OpenAI multi-part content — an array of {type:"text",text:"…"}
// parts — into a plain string. The gateway only accepts string content
// and returns HTTP 400 for arrays.
//
// 2. Sanitizes tool_calls that the gateway would reject: calls whose
// function name is not in the declared tools set (e.g. "invalid"
// placeholders from AI coding tools when a tool call fails), or whose
// arguments are not a valid JSON object. Instead of removing these
// calls (which would lose error feedback the model needs), the function
// name is replaced with a valid one and the arguments are replaced with
// "{}". The corresponding tool result messages are preserved so the
// model still sees the full conversation including errors.
//
// Both []any (from JSON decoding of /v1/chat/completions) and
// []map[string]any (from convertContentParts in the Responses API path)
// are handled for content arrays.
func normalizeMessages(messages []map[string]any, validToolNames map[string]bool) []map[string]any {
fallbackName := pickFallbackToolName(validToolNames)
for _, msg := range messages {
// --- flatten multi-part text content ---
parts := contentAsAnySlice(msg["content"])
if len(parts) > 0 {
var sb strings.Builder
allText := true
for _, p := range parts {
part, ok := p.(map[string]any)
if !ok {
allText = false
break
}
pt, _ := part["type"].(string)
if pt != "text" && pt != "input_text" && pt != "output_text" {
allText = false
break
}
text, _ := part["text"].(string)
sb.WriteString(text)
}
if allText {
msg["content"] = sb.String()
}
}
// --- sanitize tool_calls in place ---
tcs := contentAsAnySlice(msg["tool_calls"])
for _, tc := range tcs {
if tcMap, ok := tc.(map[string]any); ok {
sanitizeToolCall(tcMap, validToolNames, fallbackName)
}
}
}
return messages
}
// sanitizeToolCall fixes a tool_call in place so the upstream gateway
// accepts it. Two fields are corrected:
//
// - function.name: if the name is not among the declared tools (when the
// set is non-empty), it is replaced. The replacement is extracted from
// the arguments' "tool" field (MiMoCode stores the real tool name there
// in error placeholders); failing that, an arbitrary declared tool name
// is used.
//
// - function.arguments: if the value is not a valid JSON object string
// (e.g. "-1", "", "true"), it is replaced with "{}".
//
// The tool_call id, type, and the tool result messages are left untouched,
// preserving the full conversation context for the model.
func sanitizeToolCall(tc map[string]any, validToolNames map[string]bool, fallbackName string) {
fn, ok := tc["function"].(map[string]any)
if !ok {
return
}
// Fix function name if not in the valid set.
name, _ := fn["name"].(string)
if len(validToolNames) > 0 && !validToolNames[name] {
args, _ := fn["arguments"].(string)
if extracted := extractToolNameFromArgs(args); validToolNames[extracted] {
fn["name"] = extracted
} else if fallbackName != "" {
fn["name"] = fallbackName
}
}
// Fix arguments if not a valid JSON object.
args, _ := fn["arguments"].(string)
if !isValidJSONObject(args) {
fn["arguments"] = "{}"
}
}
// extractToolNameFromArgs attempts to read a "tool" field from the JSON
// object in args. MiMoCode's error placeholders store the real tool name
// here (e.g. {"tool":"task","error":"…"}).
func extractToolNameFromArgs(args string) string {
var m map[string]any
if json.Unmarshal([]byte(args), &m) != nil {
return ""
}
tool, _ := m["tool"].(string)
return tool
}
// pickFallbackToolName returns an arbitrary name from the set for use as a
// last-resort replacement when no real tool name can be extracted.
func pickFallbackToolName(names map[string]bool) string {
for name := range names {
return name
}
return ""
}
// isValidJSONObject reports whether s is a non-empty JSON string that decodes
// to a JSON object (map[string]any). The OpenAI tool_call arguments field
// must be a JSON object string like {"key":"value"}; the Zhanlu gateway
// rejects values like "-1", "true", or "" with HTTP 400.
func isValidJSONObject(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
return false
}
_, ok := v.(map[string]any)
return ok
}
// contentAsAnySlice returns the content value as a []any, handling both
// []any (JSON decoding) and []map[string]any (convertContentParts). It
// returns nil for non-slice values (strings, nil, etc.).
func contentAsAnySlice(v any) []any {
switch s := v.(type) {
case []any:
return s
case []map[string]any:
result := make([]any, len(s))
for i, m := range s {
result[i] = m
}
return result
default:
return nil
}
}
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
+486
View File
@@ -0,0 +1,486 @@
package openai
import (
"encoding/json"
"strings"
"testing"
)
func TestMarshalForUpstream_StringContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Messages: []map[string]any{{"role": "user", "content": "hello"}},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
if content, _ := msg["content"].(string); content != "hello" {
t.Fatalf("content = %v, want %q", msg["content"], "hello")
}
}
// TestMarshalForUpstream_FlattensArrayContent verifies that multi-part
// content arrays (the format used by OpenAI SDKs and AI coding tools) are
// flattened into a plain string so the Zhanlu upstream gateway accepts the
// request instead of returning HTTP 400.
func TestMarshalForUpstream_FlattensArrayContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "zhanlu/deepseek-v4-pro",
Messages: []map[string]any{
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "first part "},
map[string]any{"type": "text", "text": "second part"},
}},
},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
content, ok := msg["content"].(string)
if !ok {
t.Fatalf("content type = %T, want string", msg["content"])
}
if content != "first part second part" {
t.Fatalf("content = %q, want %q", content, "first part second part")
}
}
// TestMarshalForUpstream_PreservesNonTextArrayContent verifies that content
// arrays containing non-text parts (e.g. images) are left intact rather than
// being flattened, so the upstream can handle multimodal content.
func TestMarshalForUpstream_PreservesNonTextArrayContent(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Messages: []map[string]any{
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe this"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,abc"}},
}},
},
Stream: true,
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
msg, _ := messages[0].(map[string]any)
parts, ok := msg["content"].([]any)
if !ok {
t.Fatalf("content type = %T, want []any (preserved array)", msg["content"])
}
if len(parts) != 2 {
t.Fatalf("parts = %d, want 2", len(parts))
}
}
// TestMarshalForUpstream_ExtraFields verifies that extra fields (max_tokens,
// tools, etc.) are passed through to the upstream body.
func TestMarshalForUpstream_ExtraFields(t *testing.T) {
req := ChatCompletionRequest{
Model: "GLM-4.7",
Stream: true,
Extra: map[string]json.RawMessage{
"max_tokens": json.RawMessage(`32000`),
"tool_choice": json.RawMessage(`"auto"`),
},
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
if v, _ := m["max_tokens"].(float64); v != 32000 {
t.Fatalf("max_tokens = %v, want 32000", m["max_tokens"])
}
if v, _ := m["tool_choice"].(string); v != "auto" {
t.Fatalf("tool_choice = %v, want \"auto\"", m["tool_choice"])
}
}
// TestMarshalForUpstream_GLMToolStream verifies that tool_stream is added
// for GLM models but not for non-GLM models.
func TestMarshalForUpstream_GLMToolStream(t *testing.T) {
cases := []struct {
model string
wantTool bool
}{
{"GLM-4.7", true},
{"zhanlu/glm-4-pro", true},
{"zhanlu/deepseek-v4-pro", false},
{"deepseek-chat", false},
}
for _, tc := range cases {
req := ChatCompletionRequest{Model: tc.model, Stream: true}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
_ = json.Unmarshal(body, &m)
_, has := m["tool_stream"]
if has != tc.wantTool {
t.Fatalf("model %q: tool_stream present = %v, want %v", tc.model, has, tc.wantTool)
}
}
}
// TestNormalizeMessages_MixedContent verifies a mix of string and array
// content across multiple messages in a single request.
func TestNormalizeMessages_MixedContent(t *testing.T) {
messages := []map[string]any{
{"role": "system", "content": "you are helpful"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "part 1 "},
map[string]any{"type": "text", "text": "part 2"},
}},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "only part"},
}},
}
result := normalizeMessages(messages, nil)
// system message unchanged
if c, _ := result[0]["content"].(string); c != "you are helpful" {
t.Fatalf("msg[0] content = %v", result[0]["content"])
}
// array content flattened
if c, _ := result[1]["content"].(string); c != "part 1 part 2" {
t.Fatalf("msg[1] content = %v, want %q", result[1]["content"], "part 1 part 2")
}
// string content unchanged
if c, _ := result[2]["content"].(string); c != "ok" {
t.Fatalf("msg[2] content = %v", result[2]["content"])
}
// single-part array flattened to string
if c, _ := result[3]["content"].(string); c != "only part" {
t.Fatalf("msg[3] content = %v, want %q", result[3]["content"], "only part")
}
}
// TestNormalizeMessages_EmptyArray verifies that an empty content array
// becomes an empty string.
func TestNormalizeMessages_EmptyArray(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": []any{}},
}
result := normalizeMessages(messages, nil)
if c, _ := result[0]["content"].(string); c != "" {
t.Fatalf("content = %v, want empty string", result[0]["content"])
}
}
// TestNormalizeMessages_MapSliceContent verifies that content stored as
// []map[string]any (produced by convertContentParts in the Responses API
// path) is also flattened, not just []any (from JSON decoding).
func TestNormalizeMessages_MapSliceContent(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": []map[string]any{
{"type": "text", "text": "map part 1 "},
{"type": "text", "text": "map part 2"},
}},
}
result := normalizeMessages(messages, nil)
if c, _ := result[0]["content"].(string); c != "map part 1 map part 2" {
t.Fatalf("content = %v, want %q", result[0]["content"], "map part 1 map part 2")
}
}
// TestMarshalForUpstream_ErrorRequestReplay is a regression test mirroring
// the real failing request from error.md: a multi-turn conversation with
// system prompt, user messages with multi-part content arrays, assistant
// messages with tool_calls and reasoning_content, and tool result messages.
// It verifies that no message has array content after marshaling.
func TestMarshalForUpstream_ErrorRequestReplay(t *testing.T) {
req := ChatCompletionRequest{
Model: "zhanlu/deepseek-v4-pro",
Messages: []map[string]any{
{"role": "system", "content": strings.Repeat("system prompt ", 100)},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "user question"},
map[string]any{"type": "text", "text": "<system-reminder>skill search</system-reminder>"},
}},
{"role": "assistant", "content": "I will search.", "reasoning_content": "thinking...", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "file1\nfile2"},
{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "continue"},
}},
},
Stream: true,
Extra: map[string]json.RawMessage{
"max_tokens": json.RawMessage(`32000`),
"tool_choice": json.RawMessage(`"auto"`),
},
}
body, err := req.MarshalForUpstream()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
messages, _ := m["messages"].([]any)
for i, raw := range messages {
msg, _ := raw.(map[string]any)
switch c := msg["content"].(type) {
case string:
// OK — flattened
case []any:
t.Fatalf("message [%d] still has array content after marshal", i)
default:
t.Fatalf("message [%d] content type = %T", i, c)
}
}
}
// TestNormalizeMessages_SanitizesInvalidToolCallNames verifies that
// tool_calls whose function name is not in the declared tools set are
// fixed in place — the name is replaced with a valid one — rather than
// removed. This preserves the conversation context including error
// feedback. MiMoCode emits {name:"invalid"} placeholders when a tool
// call fails; the Zhanlu gateway rejects unknown function names with
// HTTP 400.
func TestNormalizeMessages_SanitizesInvalidToolCallNames(t *testing.T) {
validNames := map[string]bool{"bash": true, "read": true}
messages := []map[string]any{
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "I'll run bash.", "tool_calls": []any{
map[string]any{"id": "call_ok", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
// "invalid" name with "tool" field in args — should be extracted
map[string]any{"id": "call_bad", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"read","error":"failed"}`}},
}},
{"role": "tool", "tool_call_id": "call_ok", "content": "file1"},
{"role": "tool", "tool_call_id": "call_bad", "content": "error result"},
{"role": "user", "content": "thanks"},
}
result := normalizeMessages(messages, validNames)
// All messages preserved (no removal)
if len(result) != 5 {
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
}
// Both tool_calls kept; the invalid one's name should be fixed to "read"
// (extracted from args.tool)
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 2 {
t.Fatalf("tool_calls = %d items, want 2 (preserved)", len(tcs))
}
tc1, _ := tcs[0].(map[string]any)
fn1, _ := tc1["function"].(map[string]any)
if name, _ := fn1["name"].(string); name != "bash" {
t.Fatalf("first tool_call name = %q, want %q", name, "bash")
}
tc2, _ := tcs[1].(map[string]any)
fn2, _ := tc2["function"].(map[string]any)
if name, _ := fn2["name"].(string); name != "read" {
t.Fatalf("sanitized tool_call name = %q, want %q", name, "read")
}
// Tool results preserved
toolMsg := result[3]
if id, _ := toolMsg["tool_call_id"].(string); id != "call_bad" {
t.Fatalf("tool_call_id = %q, want %q", id, "call_bad")
}
}
// TestNormalizeMessages_SanitizesInvalidArguments verifies that tool_calls
// whose arguments are not a valid JSON object string (e.g. "-1", "",
// "true") are fixed to "{}" in place, not removed. The Zhanlu gateway
// requires arguments to be a JSON object.
func TestNormalizeMessages_SanitizesInvalidArguments(t *testing.T) {
validNames := map[string]bool{"task": true}
messages := []map[string]any{
{"role": "user", "content": "create tasks"},
{"role": "assistant", "content": "creating.", "tool_calls": []any{
map[string]any{"id": "call_good", "type": "function", "function": map[string]any{"name": "task", "arguments": `{"operation":"create","summary":"test"}`}},
map[string]any{"id": "call_bad1", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
map[string]any{"id": "call_bad2", "type": "function", "function": map[string]any{"name": "task", "arguments": ""}},
}},
{"role": "tool", "tool_call_id": "call_good", "content": "created"},
{"role": "tool", "tool_call_id": "call_bad1", "content": "error"},
{"role": "tool", "tool_call_id": "call_bad2", "content": "error"},
}
result := normalizeMessages(messages, validNames)
// All 5 messages preserved
if len(result) != 5 {
t.Fatalf("messages = %d, want 5 (sanitize preserves all)", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 3 {
t.Fatalf("tool_calls = %d items, want 3 (preserved)", len(tcs))
}
// Good args unchanged
tc0, _ := tcs[0].(map[string]any)
fn0, _ := tc0["function"].(map[string]any)
if args, _ := fn0["arguments"].(string); args != `{"operation":"create","summary":"test"}` {
t.Fatalf("good args changed: %q", args)
}
// Bad args fixed to "{}"
tc1, _ := tcs[1].(map[string]any)
fn1, _ := tc1["function"].(map[string]any)
if args, _ := fn1["arguments"].(string); args != "{}" {
t.Fatalf("bad1 args = %q, want {}", args)
}
tc2, _ := tcs[2].(map[string]any)
fn2, _ := tc2["function"].(map[string]any)
if args, _ := fn2["arguments"].(string); args != "{}" {
t.Fatalf("bad2 args = %q, want {}", args)
}
}
// TestNormalizeMessages_KeepsValidToolCalls verifies that valid tool_calls
// (correct name and valid JSON object arguments) are preserved unchanged.
func TestNormalizeMessages_KeepsValidToolCalls(t *testing.T) {
validNames := map[string]bool{"bash": true, "read": true}
messages := []map[string]any{
{"role": "user", "content": "list files"},
{"role": "assistant", "content": "sure.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "bash", "arguments": `{"command":"ls"}`}},
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "read", "arguments": `{"file":"a.txt"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "file1"},
{"role": "tool", "tool_call_id": "call_2", "content": "content"},
}
result := normalizeMessages(messages, validNames)
if len(result) != 4 {
t.Fatalf("messages = %d, want 4", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 2 {
t.Fatalf("tool_calls = %d items, want 2", len(tcs))
}
// Verify the first tool_call is unchanged
tc, _ := tcs[0].(map[string]any)
fn, _ := tc["function"].(map[string]any)
if name, _ := fn["name"].(string); name != "bash" {
t.Fatalf("name = %q, want %q", name, "bash")
}
if args, _ := fn["arguments"].(string); args != `{"command":"ls"}` {
t.Fatalf("args = %q, want %q", args, `{"command":"ls"}`)
}
}
// TestNormalizeMessages_NoToolNamesSkipsNameCheck verifies that when no
// tools are declared (empty map), tool_calls are not name-sanitized
// (only argument validity is checked).
func TestNormalizeMessages_NoToolNamesSkipsNameCheck(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "ok.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "custom_fn", "arguments": `{"x":1}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
}
result := normalizeMessages(messages, nil)
if len(result) != 3 {
t.Fatalf("messages = %d, want 3", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
tc, _ := tcs[0].(map[string]any)
fn, _ := tc["function"].(map[string]any)
// Name should be unchanged (no tools to validate against)
if name, _ := fn["name"].(string); name != "custom_fn" {
t.Fatalf("name = %q, want %q (should not be changed)", name, "custom_fn")
}
}
// TestNormalizeMessages_ErrorMdScenario mirrors the exact error.md
// request: an assistant message with 5 tool_calls where 4 have
// name:"invalid" (with args containing {"tool":"task","error":"…"}) and 1
// has name:"task" with args:"-1". Verifies that after sanitization, all
// 5 calls have valid names and valid JSON object arguments.
func TestNormalizeMessages_ErrorMdScenario(t *testing.T) {
validNames := map[string]bool{"task": true, "bash": true}
messages := []map[string]any{
{"role": "user", "content": "create tasks"},
{"role": "assistant", "content": "confirming.", "tool_calls": []any{
map[string]any{"id": "call_1", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_2", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_3", "type": "function", "function": map[string]any{"name": "task", "arguments": "-1"}},
map[string]any{"id": "call_4", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
map[string]any{"id": "call_5", "type": "function", "function": map[string]any{"name": "invalid", "arguments": `{"tool":"task","error":"JSON parsing failed"}`}},
}},
{"role": "tool", "tool_call_id": "call_1", "content": "error 1"},
{"role": "tool", "tool_call_id": "call_2", "content": "error 2"},
{"role": "tool", "tool_call_id": "call_3", "content": "error 3"},
{"role": "tool", "tool_call_id": "call_4", "content": "error 4"},
{"role": "tool", "tool_call_id": "call_5", "content": "error 5"},
}
result := normalizeMessages(messages, validNames)
// All 7 messages preserved (no removal)
if len(result) != 7 {
t.Fatalf("messages = %d, want 7", len(result))
}
asst := result[1]
tcs, _ := asst["tool_calls"].([]any)
if len(tcs) != 5 {
t.Fatalf("tool_calls = %d items, want 5", len(tcs))
}
for i, raw := range tcs {
tc, _ := raw.(map[string]any)
fn, _ := tc["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
// All names should be "task" (extracted from args or already valid)
if name != "task" {
t.Errorf("tool_call[%d] name = %q, want %q", i, name, "task")
}
// All args should be valid JSON objects
if !isValidJSONObject(args) {
t.Errorf("tool_call[%d] args = %q, not a valid JSON object", i, args)
}
}
}
// TestIsValidJSONObject verifies the JSON object validation used to filter
// tool_call arguments.
func TestIsValidJSONObject(t *testing.T) {
cases := []struct {
input string
want bool
}{
{`{"key":"value"}`, true},
{`{}`, true},
{`{"nested":{"a":1}}`, true},
{`-1`, false},
{`true`, false},
{`"string"`, false},
{`[1,2,3]`, false},
{``, false},
{` `, false},
{`{invalid json}`, false},
}
for _, tc := range cases {
if got := isValidJSONObject(tc.input); got != tc.want {
t.Errorf("isValidJSONObject(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"bytes"
"fmt"
"io"
"net/http"
"sync"
"time"
)
const (
maxRecentEntries = 10
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
)
// RecentEntry captures a single API request and its response for debugging.
type RecentEntry struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
DurationMs int64 `json:"duration_ms"`
RequestHeaders map[string]string `json:"request_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
}
// RecentRecorder is an in-memory ring buffer that stores the most recent API
// requests. It is safe for concurrent use.
type RecentRecorder struct {
mu sync.Mutex
entries []RecentEntry
}
func NewRecentRecorder() *RecentRecorder {
return &RecentRecorder{}
}
// Record appends an entry, evicting the oldest when the buffer is full.
func (r *RecentRecorder) Record(e RecentEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
if len(r.entries) > maxRecentEntries {
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
}
}
// Entries returns a copy of the buffer in newest-first order.
func (r *RecentRecorder) Entries() []RecentEntry {
r.mu.Lock()
defer r.mu.Unlock()
n := len(r.entries)
out := make([]RecentEntry, n)
for i, e := range r.entries {
out[n-1-i] = e // reverse: newest first
}
return out
}
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
// and a truncated copy of the response body. It implements http.Flusher so
// streaming handlers can flush through the wrapper.
type recordingResponseWriter struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
totalBytes int
}
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (w *recordingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
w.totalBytes += len(b)
if w.body.Len() < maxBodyCapture {
remaining := maxBodyCapture - w.body.Len()
if len(b) <= remaining {
w.body.Write(b)
} else {
w.body.Write(b[:remaining])
}
}
return w.ResponseWriter.Write(b)
}
func (w *recordingResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// captureRequestHeaders extracts selected request headers, masking the
// Authorization value to avoid leaking API keys.
func captureRequestHeaders(r *http.Request) map[string]string {
headers := map[string]string{}
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
if v := r.Header.Get(key); v != "" {
if key == "Authorization" {
headers[key] = mask(v)
} else {
headers[key] = v
}
}
}
return headers
}
// truncateBody returns the string form of b, truncated to maxBodyCapture
// bytes with a marker if the original was longer.
func truncateBody(b []byte) string {
if len(b) > maxBodyCapture {
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// formatResponseBody returns the captured response body, with a truncation
// marker if the full response exceeded the capture limit.
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
s := buf.String()
if totalBytes > maxBodyCapture {
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
}
return s
}
// withRecording wraps a handler so that each request's headers, body,
// response status, and response body (truncated) are captured into the
// server's RecentRecorder. It is applied to the /v1/ API routes so that
// both successful and error responses are recorded.
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Read and restore the request body so the downstream handler
// still sees the full content.
var reqBody []byte
if r.Body != nil {
reqBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(reqBody))
}
recW := newRecordingResponseWriter(w)
next(recW, r)
s.recorder.Record(RecentEntry{
Timestamp: start,
Method: r.Method,
Path: r.URL.Path,
Status: recW.statusCode,
DurationMs: time.Since(start).Milliseconds(),
RequestHeaders: captureRequestHeaders(r),
RequestBody: truncateBody(reqBody),
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
})
}
}
// getRecent handles GET /api/recent — returns the most recent API requests
// as JSON, newest first.
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"requests": s.recorder.Entries(),
})
}
+148
View File
@@ -0,0 +1,148 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
// maxRecentEntries and evicts the oldest when full.
func TestRecentRecorderRingBuffer(t *testing.T) {
rec := NewRecentRecorder()
for i := 0; i < maxRecentEntries+5; i++ {
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
}
entries := rec.Entries()
if len(entries) != maxRecentEntries {
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
}
}
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
func TestRecentRecorderNewestFirst(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Path: "/first"})
rec.Record(RecentEntry{Path: "/second"})
rec.Record(RecentEntry{Path: "/third"})
entries := rec.Entries()
if len(entries) != 3 {
t.Fatalf("got %d entries", len(entries))
}
if entries[0].Path != "/third" {
t.Fatalf("first entry = %q, want /third", entries[0].Path)
}
if entries[2].Path != "/first" {
t.Fatalf("last entry = %q, want /first", entries[2].Path)
}
}
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
// request headers, request body, response status, and response body.
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result":"ok"}`))
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
req.Header.Set("User-Agent", "test-client/1.0")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Method != "POST" {
t.Fatalf("method = %q", e.Method)
}
if e.Path != "/v1/chat/completions" {
t.Fatalf("path = %q", e.Path)
}
if e.Status != 200 {
t.Fatalf("status = %d", e.Status)
}
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
t.Fatalf("request body = %q", e.RequestBody)
}
if e.ResponseBody != `{"result":"ok"}` {
t.Fatalf("response body = %q", e.ResponseBody)
}
// Authorization must be masked
auth, ok := e.RequestHeaders["Authorization"]
if !ok || !strings.Contains(auth, "****") {
t.Fatalf("authorization not masked: %q", auth)
}
if strings.Contains(auth, "sk-secret-key-12345") {
t.Fatal("authorization leaked raw key")
}
if e.RequestHeaders["Content-Type"] != "application/json" {
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
}
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
}
}
// TestWithRecordingCapturesErrors verifies error responses are recorded.
func TestWithRecordingCapturesErrors(t *testing.T) {
rec := NewRecentRecorder()
s := &Server{recorder: rec}
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
req.Header.Set("Authorization", "Bearer wrong-key")
w := httptest.NewRecorder()
handler(w, req)
entries := rec.Entries()
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.Status != 401 {
t.Fatalf("status = %d, want 401", e.Status)
}
if !strings.Contains(e.ResponseBody, "invalid api key") {
t.Fatalf("response body = %q", e.ResponseBody)
}
}
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
func TestGetRecentAPI(t *testing.T) {
rec := NewRecentRecorder()
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
s := &Server{recorder: rec}
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
w := httptest.NewRecorder()
s.getRecent(w, req)
body, _ := io.ReadAll(w.Body)
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("response missing ok:true: %s", string(body))
}
if !strings.Contains(string(body), "/v1/responses") {
t.Fatalf("response missing /v1/responses: %s", string(body))
}
// newest first
idxResponses := strings.Index(string(body), "/v1/responses")
idxChat := strings.Index(string(body), "/v1/chat/completions")
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
t.Fatalf("entries not newest-first: %s", string(body))
}
}
+6 -4
View File
@@ -33,10 +33,11 @@ type Server struct {
loginSession string
st *store.Store
statsEnabled bool
recorder *RecentRecorder
}
func New(cfg config.Config, st *store.Store) http.Handler {
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()}
if cfg.LoginPassword != "" {
s.loginSession = randomSessionToken()
}
@@ -62,11 +63,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent))
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses))
s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models)))
s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses)))
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
+83
View File
@@ -101,6 +101,16 @@
@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>
@@ -116,6 +126,7 @@
<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>
@@ -191,6 +202,17 @@
</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>
@@ -225,6 +247,7 @@
});
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');
@@ -521,6 +544,66 @@
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>