Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d78c4576 | ||
|
|
f988c47fec | ||
|
|
f51ec09a09 | ||
|
|
b9a3f8d5cd |
@@ -3,6 +3,7 @@ package openai
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Responses API request parsing & conversion ---
|
// --- Responses API request parsing & conversion ---
|
||||||
@@ -194,9 +195,7 @@ func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) {
|
|||||||
inner["name"] = name
|
inner["name"] = name
|
||||||
}
|
}
|
||||||
if v, ok := item["arguments"]; ok {
|
if v, ok := item["arguments"]; ok {
|
||||||
var args string
|
inner["arguments"] = rawJSONToString(v)
|
||||||
_ = json.Unmarshal(v, &args)
|
|
||||||
inner["arguments"] = args
|
|
||||||
}
|
}
|
||||||
if v, ok := item["call_id"]; ok {
|
if v, ok := item["call_id"]; ok {
|
||||||
var id string
|
var id string
|
||||||
@@ -215,9 +214,7 @@ func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) {
|
|||||||
msg["tool_call_id"] = id
|
msg["tool_call_id"] = id
|
||||||
}
|
}
|
||||||
if v, ok := item["output"]; ok {
|
if v, ok := item["output"]; ok {
|
||||||
var out string
|
msg["content"] = outputToString(v)
|
||||||
_ = json.Unmarshal(v, &out)
|
|
||||||
msg["content"] = out
|
|
||||||
}
|
}
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
default:
|
default:
|
||||||
@@ -279,6 +276,58 @@ func convertContentParts(raw json.RawMessage) any {
|
|||||||
return result
|
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
|
// translateResponsesTools converts tools from the Responses API flat format
|
||||||
// to the Chat Completions nested {type:"function",function:{…}} format.
|
// to the Chat Completions nested {type:"function",function:{…}} format.
|
||||||
func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
|
func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
|||||||
@@ -191,6 +191,90 @@ func TestParseResponsesRequest_FunctionCallInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestUsageToResponses(t *testing.T) {
|
||||||
usage := map[string]any{
|
usage := map[string]any{
|
||||||
"prompt_tokens": 10,
|
"prompt_tokens": 10,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package openai
|
package openai
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type ChatCompletionRequest struct {
|
type ChatCompletionRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -40,6 +43,11 @@ func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
|||||||
m[k] = anyValue
|
m[k] = anyValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mirror the Zhanlu plugin: GLM models require {tool_stream:true} to
|
||||||
|
// stream tool calls back to the client (matches /glm/i.test(model)).
|
||||||
|
if strings.Contains(strings.ToLower(r.Model), "glm") {
|
||||||
|
m["tool_stream"] = true
|
||||||
|
}
|
||||||
return json.Marshal(m)
|
return json.Marshal(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRecentEntries = 10
|
||||||
|
maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecentEntry captures a single API request and its response for debugging.
|
||||||
|
type RecentEntry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
RequestHeaders map[string]string `json:"request_headers"`
|
||||||
|
RequestBody string `json:"request_body"`
|
||||||
|
ResponseBody string `json:"response_body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRecorder is an in-memory ring buffer that stores the most recent API
|
||||||
|
// requests. It is safe for concurrent use.
|
||||||
|
type RecentRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []RecentEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecentRecorder() *RecentRecorder {
|
||||||
|
return &RecentRecorder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record appends an entry, evicting the oldest when the buffer is full.
|
||||||
|
func (r *RecentRecorder) Record(e RecentEntry) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.entries = append(r.entries, e)
|
||||||
|
if len(r.entries) > maxRecentEntries {
|
||||||
|
r.entries = r.entries[len(r.entries)-maxRecentEntries:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries returns a copy of the buffer in newest-first order.
|
||||||
|
func (r *RecentRecorder) Entries() []RecentEntry {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
n := len(r.entries)
|
||||||
|
out := make([]RecentEntry, n)
|
||||||
|
for i, e := range r.entries {
|
||||||
|
out[n-1-i] = e // reverse: newest first
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingResponseWriter wraps http.ResponseWriter to capture the status code
|
||||||
|
// and a truncated copy of the response body. It implements http.Flusher so
|
||||||
|
// streaming handlers can flush through the wrapper.
|
||||||
|
type recordingResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
body bytes.Buffer
|
||||||
|
totalBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter {
|
||||||
|
return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingResponseWriter) WriteHeader(code int) {
|
||||||
|
w.statusCode = code
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
w.totalBytes += len(b)
|
||||||
|
if w.body.Len() < maxBodyCapture {
|
||||||
|
remaining := maxBodyCapture - w.body.Len()
|
||||||
|
if len(b) <= remaining {
|
||||||
|
w.body.Write(b)
|
||||||
|
} else {
|
||||||
|
w.body.Write(b[:remaining])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *recordingResponseWriter) Flush() {
|
||||||
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureRequestHeaders extracts selected request headers, masking the
|
||||||
|
// Authorization value to avoid leaking API keys.
|
||||||
|
func captureRequestHeaders(r *http.Request) map[string]string {
|
||||||
|
headers := map[string]string{}
|
||||||
|
for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} {
|
||||||
|
if v := r.Header.Get(key); v != "" {
|
||||||
|
if key == "Authorization" {
|
||||||
|
headers[key] = mask(v)
|
||||||
|
} else {
|
||||||
|
headers[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateBody returns the string form of b, truncated to maxBodyCapture
|
||||||
|
// bytes with a marker if the original was longer.
|
||||||
|
func truncateBody(b []byte) string {
|
||||||
|
if len(b) > maxBodyCapture {
|
||||||
|
return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b))
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatResponseBody returns the captured response body, with a truncation
|
||||||
|
// marker if the full response exceeded the capture limit.
|
||||||
|
func formatResponseBody(buf *bytes.Buffer, totalBytes int) string {
|
||||||
|
s := buf.String()
|
||||||
|
if totalBytes > maxBodyCapture {
|
||||||
|
s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRecording wraps a handler so that each request's headers, body,
|
||||||
|
// response status, and response body (truncated) are captured into the
|
||||||
|
// server's RecentRecorder. It is applied to the /v1/ API routes so that
|
||||||
|
// both successful and error responses are recorded.
|
||||||
|
func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Read and restore the request body so the downstream handler
|
||||||
|
// still sees the full content.
|
||||||
|
var reqBody []byte
|
||||||
|
if r.Body != nil {
|
||||||
|
reqBody, _ = io.ReadAll(r.Body)
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(reqBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
recW := newRecordingResponseWriter(w)
|
||||||
|
next(recW, r)
|
||||||
|
|
||||||
|
s.recorder.Record(RecentEntry{
|
||||||
|
Timestamp: start,
|
||||||
|
Method: r.Method,
|
||||||
|
Path: r.URL.Path,
|
||||||
|
Status: recW.statusCode,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
RequestHeaders: captureRequestHeaders(r),
|
||||||
|
RequestBody: truncateBody(reqBody),
|
||||||
|
ResponseBody: formatResponseBody(&recW.body, recW.totalBytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRecent handles GET /api/recent — returns the most recent API requests
|
||||||
|
// as JSON, newest first.
|
||||||
|
func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"requests": s.recorder.Entries(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRecentRecorderRingBuffer verifies that the recorder keeps at most
|
||||||
|
// maxRecentEntries and evicts the oldest when full.
|
||||||
|
func TestRecentRecorderRingBuffer(t *testing.T) {
|
||||||
|
rec := NewRecentRecorder()
|
||||||
|
for i := 0; i < maxRecentEntries+5; i++ {
|
||||||
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200})
|
||||||
|
}
|
||||||
|
entries := rec.Entries()
|
||||||
|
if len(entries) != maxRecentEntries {
|
||||||
|
t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentRecorderNewestFirst verifies entries are returned newest-first.
|
||||||
|
func TestRecentRecorderNewestFirst(t *testing.T) {
|
||||||
|
rec := NewRecentRecorder()
|
||||||
|
rec.Record(RecentEntry{Path: "/first"})
|
||||||
|
rec.Record(RecentEntry{Path: "/second"})
|
||||||
|
rec.Record(RecentEntry{Path: "/third"})
|
||||||
|
entries := rec.Entries()
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Path != "/third" {
|
||||||
|
t.Fatalf("first entry = %q, want /third", entries[0].Path)
|
||||||
|
}
|
||||||
|
if entries[2].Path != "/first" {
|
||||||
|
t.Fatalf("last entry = %q, want /first", entries[2].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithRecordingCapturesRequestResponse verifies the middleware captures
|
||||||
|
// request headers, request body, response status, and response body.
|
||||||
|
func TestWithRecordingCapturesRequestResponse(t *testing.T) {
|
||||||
|
rec := NewRecentRecorder()
|
||||||
|
s := &Server{recorder: rec}
|
||||||
|
|
||||||
|
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"result":"ok"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-secret-key-12345")
|
||||||
|
req.Header.Set("User-Agent", "test-client/1.0")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
entries := rec.Entries()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("got %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
e := entries[0]
|
||||||
|
if e.Method != "POST" {
|
||||||
|
t.Fatalf("method = %q", e.Method)
|
||||||
|
}
|
||||||
|
if e.Path != "/v1/chat/completions" {
|
||||||
|
t.Fatalf("path = %q", e.Path)
|
||||||
|
}
|
||||||
|
if e.Status != 200 {
|
||||||
|
t.Fatalf("status = %d", e.Status)
|
||||||
|
}
|
||||||
|
if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` {
|
||||||
|
t.Fatalf("request body = %q", e.RequestBody)
|
||||||
|
}
|
||||||
|
if e.ResponseBody != `{"result":"ok"}` {
|
||||||
|
t.Fatalf("response body = %q", e.ResponseBody)
|
||||||
|
}
|
||||||
|
// Authorization must be masked
|
||||||
|
auth, ok := e.RequestHeaders["Authorization"]
|
||||||
|
if !ok || !strings.Contains(auth, "****") {
|
||||||
|
t.Fatalf("authorization not masked: %q", auth)
|
||||||
|
}
|
||||||
|
if strings.Contains(auth, "sk-secret-key-12345") {
|
||||||
|
t.Fatal("authorization leaked raw key")
|
||||||
|
}
|
||||||
|
if e.RequestHeaders["Content-Type"] != "application/json" {
|
||||||
|
t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"])
|
||||||
|
}
|
||||||
|
if e.RequestHeaders["User-Agent"] != "test-client/1.0" {
|
||||||
|
t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithRecordingCapturesErrors verifies error responses are recorded.
|
||||||
|
func TestWithRecordingCapturesErrors(t *testing.T) {
|
||||||
|
rec := NewRecentRecorder()
|
||||||
|
s := &Server{recorder: rec}
|
||||||
|
|
||||||
|
handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
|
||||||
|
entries := rec.Entries()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("got %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
e := entries[0]
|
||||||
|
if e.Status != 401 {
|
||||||
|
t.Fatalf("status = %d, want 401", e.Status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(e.ResponseBody, "invalid api key") {
|
||||||
|
t.Fatalf("response body = %q", e.ResponseBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON.
|
||||||
|
func TestGetRecentAPI(t *testing.T) {
|
||||||
|
rec := NewRecentRecorder()
|
||||||
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200})
|
||||||
|
rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500})
|
||||||
|
s := &Server{recorder: rec}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/recent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.getRecent(w, req)
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(w.Body)
|
||||||
|
if !strings.Contains(string(body), `"ok":true`) {
|
||||||
|
t.Fatalf("response missing ok:true: %s", string(body))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "/v1/responses") {
|
||||||
|
t.Fatalf("response missing /v1/responses: %s", string(body))
|
||||||
|
}
|
||||||
|
// newest first
|
||||||
|
idxResponses := strings.Index(string(body), "/v1/responses")
|
||||||
|
idxChat := strings.Index(string(body), "/v1/chat/completions")
|
||||||
|
if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat {
|
||||||
|
t.Fatalf("entries not newest-first: %s", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,10 +33,11 @@ type Server struct {
|
|||||||
loginSession string
|
loginSession string
|
||||||
st *store.Store
|
st *store.Store
|
||||||
statsEnabled bool
|
statsEnabled bool
|
||||||
|
recorder *RecentRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, st *store.Store) http.Handler {
|
func New(cfg config.Config, st *store.Store) http.Handler {
|
||||||
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled}
|
s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()}
|
||||||
if cfg.LoginPassword != "" {
|
if cfg.LoginPassword != "" {
|
||||||
s.loginSession = randomSessionToken()
|
s.loginSession = randomSessionToken()
|
||||||
}
|
}
|
||||||
@@ -62,11 +63,12 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||||
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
||||||
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
||||||
|
s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent))
|
||||||
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
|
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
|
||||||
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models)))
|
||||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions)))
|
||||||
s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses))
|
s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -440,7 +442,7 @@ func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *p
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("plugin_type", "zhanlu_ide")
|
req.Header.Set("plugin_type", "zhanlu_ide")
|
||||||
req.Header.Set("plugin_version", s.cfg.PluginVersion)
|
req.Header.Set("plugin_version", s.cfg.PluginVersion)
|
||||||
req.Header.Set("request", randomRequestID())
|
req.Header.Set("request", util.RandomRequestID())
|
||||||
resp, err := s.upstreamHTTPClient().Do(req)
|
resp, err := s.upstreamHTTPClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1093,10 +1095,6 @@ func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
|
|||||||
if payload == "" || payload == "[DONE]" {
|
if payload == "" || payload == "[DONE]" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var upstreamError map[string]any
|
|
||||||
if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
|
|
||||||
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
|
|
||||||
}
|
|
||||||
if err := fn([]byte(payload)); err != nil {
|
if err := fn([]byte(payload)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,16 @@
|
|||||||
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
|
@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{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}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -116,6 +126,7 @@
|
|||||||
<div class="tabs" role="tablist">
|
<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="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="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>
|
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,6 +202,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="tabpanel" data-tab="login" role="tabpanel">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
||||||
@@ -225,6 +247,7 @@
|
|||||||
});
|
});
|
||||||
var hash = location.hash.replace('#', '');
|
var hash = location.hash.replace('#', '');
|
||||||
if (hash === 'models') activate('models');
|
if (hash === 'models') activate('models');
|
||||||
|
else if (hash === 'recent') activate('recent');
|
||||||
else if (hash === 'login') activate('login');
|
else if (hash === 'login') activate('login');
|
||||||
|
|
||||||
var rows = document.querySelectorAll('#daily .bar');
|
var rows = document.querySelectorAll('#daily .bar');
|
||||||
@@ -521,6 +544,66 @@
|
|||||||
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
|
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
|
||||||
loadIfActive();
|
loadIfActive();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var recentList = document.getElementById('recent-list');
|
||||||
|
if (!recentList) return;
|
||||||
|
var loaded = false;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formatTime(ts) {
|
||||||
|
var d = new Date(ts);
|
||||||
|
return d.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
async function loadRecent() {
|
||||||
|
recentList.innerHTML = '<p class="muted">加载中…</p>';
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/recent');
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.ok || !data.requests || data.requests.length === 0) {
|
||||||
|
recentList.innerHTML = '<p class="empty">暂无数据</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < data.requests.length; i++) {
|
||||||
|
var r = data.requests[i];
|
||||||
|
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
|
||||||
|
var headers = r.request_headers || {};
|
||||||
|
var headerStr = Object.keys(headers).map(function (k) {
|
||||||
|
return k + ': ' + headers[k];
|
||||||
|
}).join('\n');
|
||||||
|
html += '<div class="recent-card">' +
|
||||||
|
'<div class="recent-head">' +
|
||||||
|
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
|
||||||
|
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
|
||||||
|
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
|
||||||
|
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
|
||||||
|
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
|
||||||
|
'</div>' +
|
||||||
|
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
|
||||||
|
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
|
||||||
|
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
recentList.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var recentTab = document.querySelector('.tab[data-tab="recent"]');
|
||||||
|
if (recentTab) recentTab.addEventListener('click', function () {
|
||||||
|
if (!loaded) { loaded = true; loadRecent(); }
|
||||||
|
});
|
||||||
|
var refreshBtn = document.getElementById('recent-refresh');
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
|
||||||
|
// auto-load if the tab is active on page load
|
||||||
|
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
|
||||||
|
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+20
-1
@@ -2,7 +2,12 @@
|
|||||||
// avoid divergent same-named copies.
|
// avoid divergent same-named copies.
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// FirstNonEmpty returns the first trimmed-non-empty value, or "" when none of
|
// 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
|
// the values are non-empty. Callers that need a specific fallback for the
|
||||||
@@ -15,3 +20,17 @@ func FirstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ func (c *Client) setPluginHeaders(req *http.Request) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("plugin_type", "zhanlu_ide")
|
req.Header.Set("plugin_type", "zhanlu_ide")
|
||||||
req.Header.Set("plugin_version", c.PluginVersion)
|
req.Header.Set("plugin_version", c.PluginVersion)
|
||||||
req.Header.Set("request", randomRequestID())
|
req.Header.Set("request", util.RandomRequestID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(resp *http.Response, out any) error {
|
func decodeJSON(resp *http.Response, out any) error {
|
||||||
@@ -248,13 +248,3 @@ func randomAlnum(n int) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomRequestID() string {
|
|
||||||
b := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
b[6] = (b[6] & 0x0f) | 0x40
|
|
||||||
b[8] = (b[8] & 0x3f) | 0x80
|
|
||||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user