From ed72412ccd96951affd2eb13a9618897ebabec60 Mon Sep 17 00:00:00 2001 From: binwang219962 Date: Fri, 29 May 2026 16:19:22 +0800 Subject: [PATCH 1/2] feat(traceview): add web-based trace viewer with pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4-panel layout: openai_request → upstream_request → upstream_response → openai_response - Paginated trace list with page navigation - Collapsible sections for messages, tools, system blocks - Formatted rendering for function_call/function_call_output input items - Start script for background deployment fix(anthropic): repair empty tool names in FromCoreRequest - Defensive filter for tools with empty name field - repairEmptyToolName helper for tool_search detection - Log warning when empty-name tools are skipped chore: update .gitignore for traceview binaries --- .gitignore | 4 + cmd/traceview/index.html | 565 +++++++++++++++++++++++++ cmd/traceview/main.go | 276 ++++++++++++ cmd/traceview/start.sh | 30 ++ internal/protocol/anthropic/adapter.go | 31 +- 5 files changed, 904 insertions(+), 2 deletions(-) create mode 100644 cmd/traceview/index.html create mode 100644 cmd/traceview/main.go create mode 100644 cmd/traceview/start.sh diff --git a/.gitignore b/.gitignore index 5da6bd33..e8ed1d73 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ build/ .dev.vars /moonbridge logs/ +build.ps1 +*.exe +/traceview +traceview.exe~ diff --git a/cmd/traceview/index.html b/cmd/traceview/index.html new file mode 100644 index 00000000..77b61050 --- /dev/null +++ b/cmd/traceview/index.html @@ -0,0 +1,565 @@ + + + + +Trace Viewer + + + + +
+
+
+
+
openai_request (Codex → Bridge)
+ +
+
+
+
+
upstream_request (Bridge → Upstream)
+ +
+
+
+
+
upstream_response (Upstream → Bridge)
+
+
+
+
+
openai_response (Bridge → Codex)
+
+
+
+
+ + + + diff --git a/cmd/traceview/main.go b/cmd/traceview/main.go new file mode 100644 index 00000000..6398bf0d --- /dev/null +++ b/cmd/traceview/main.go @@ -0,0 +1,276 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" +) + +//go:embed index.html +var static embed.FS + +func main() { + port := flag.Int("port", 19999, "HTTP listen port") + flag.Parse() + + if flag.NArg() < 1 { + fmt.Fprintln(os.Stderr, "usage: traceview [-port PORT] ") + os.Exit(1) + } + sessionsRoot := flag.Arg(0) + + http.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { + listSessions(sessionsRoot, w) + }) + http.HandleFunc("/api/traces", func(w http.ResponseWriter, r *http.Request) { + dir := resolveSession(sessionsRoot, r.URL.Query().Get("session")) + if dir == "" { + http.Error(w, "session required", http.StatusBadRequest) + return + } + listTraces(dir, w, r) + }) + http.HandleFunc("/api/trace/", func(w http.ResponseWriter, r *http.Request) { + dir := resolveSession(sessionsRoot, r.URL.Query().Get("session")) + if dir == "" { + http.Error(w, "session required", http.StatusBadRequest) + return + } + numStr := strings.TrimPrefix(r.URL.Path, "/api/trace/") + num, err := strconv.Atoi(numStr) + if err != nil { + http.Error(w, "invalid trace number", http.StatusBadRequest) + return + } + getTrace(dir, num, w) + }) + http.Handle("/", http.FileServer(http.FS(static))) + + addr := fmt.Sprintf("0.0.0.0:%d", *port) + fmt.Printf("Trace viewer: http://0.0.0.0:%d\n", *port) + openBrowser(fmt.Sprintf("http://localhost:%d", *port)) + if err := http.ListenAndServe(addr, nil); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func resolveSession(root, name string) string { + if name == "" { + return "" + } + candidate := filepath.Join(root, filepath.Clean(name)) + if !strings.HasPrefix(filepath.Clean(candidate), filepath.Clean(root)) { + return "" + } + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + return "" +} + +type SessionInfo struct { + Name string `json:"name"` + TraceCount int `json:"trace_count"` + LastMod string `json:"last_modified"` +} + +func listSessions(root string, w http.ResponseWriter) { + entries, err := os.ReadDir(root) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var sessions []SessionInfo + + // tryAddSession checks a directory for a Response/ subdirectory and appends it. + tryAddSession := func(sessPath, name string) { + respDir := filepath.Join(sessPath, "Response") + info, err := os.Stat(respDir) + if err != nil || !info.IsDir() { + return + } + respEntries, err := os.ReadDir(respDir) + if err != nil { + return + } + count := 0 + for _, re := range respEntries { + if !re.IsDir() && strings.HasSuffix(re.Name(), ".json") { + count++ + } + } + if count == 0 { + return + } + sessions = append(sessions, SessionInfo{ + Name: name, + TraceCount: count, + LastMod: info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + // Level 1: check if this directory itself is a session (has Response/). + l1Path := filepath.Join(root, e.Name()) + tryAddSession(l1Path, e.Name()) + // Level 2: scan for model subdirectories (e.g. gpt-5.4/). + l1Entries, err := os.ReadDir(l1Path) + if err != nil { + continue + } + for _, l2 := range l1Entries { + if !l2.IsDir() { + continue + } + l2Path := filepath.Join(l1Path, l2.Name()) + tryAddSession(l2Path, e.Name()+"/"+l2.Name()) + } + } + + sort.Slice(sessions, func(i, j int) bool { return sessions[i].Name < sessions[j].Name }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sessions) +} + +type TraceInfo struct { + Number int `json:"number"` + Model string `json:"model"` + SessionID string `json:"session_id"` + Error string `json:"error"` + CapturedAt string `json:"captured_at"` +} + +func listTraces(dir string, w http.ResponseWriter, r *http.Request) { + respDir := filepath.Join(dir, "Response") + entries, err := os.ReadDir(respDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Collect numbers first (cheap, no file reads). + var nums []int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + num, err := strconv.Atoi(strings.TrimSuffix(e.Name(), ".json")) + if err != nil { + continue + } + nums = append(nums, num) + } + sort.Sort(sort.Reverse(sort.IntSlice(nums))) + + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + + var traces []TraceInfo + var page []int + if offset < len(nums) { + end := offset + limit + if end > len(nums) { + end = len(nums) + } + page = nums[offset:end] + } + + for _, num := range page { + info := TraceInfo{Number: num} + data, err := os.ReadFile(filepath.Join(respDir, fmt.Sprintf("%d.json", num))) + if err != nil { + traces = append(traces, info) + continue + } + var raw map[string]any + if json.Unmarshal(data, &raw) == nil { + if m, ok := raw["model"].(string); ok { + info.Model = m + } + if s, ok := raw["session_id"].(string); ok { + info.SessionID = s + } + if cap, ok := raw["captured_at"].(string); ok { + info.CapturedAt = cap + } + if errObj, ok := raw["error"].(map[string]any); ok { + if msg, ok := errObj["message"].(string); ok { + info.Error = msg + } + } + } + traces = append(traces, info) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "traces": traces, + "total": len(nums), + "has_more": offset+limit < len(nums), + }) +} + +func getTrace(dir string, num int, w http.ResponseWriter) { + respPath := filepath.Join(dir, "Response", fmt.Sprintf("%d.json", num)) + data, err := os.ReadFile(respPath) + if err != nil { + http.Error(w, "trace not found", http.StatusNotFound) + return + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + result := map[string]any{ + "number": num, + } + for _, k := range []string{"openai_request", "upstream_request", "openai_response", "error", "captured_at", "model"} { + if v, ok := raw[k]; ok { + result[k] = v + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +func openBrowser(url string) { + var cmd string + var args []string + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: + cmd = "xdg-open" + args = []string{url} + } + exec.Command(cmd, args...).Start() +} diff --git a/cmd/traceview/start.sh b/cmd/traceview/start.sh new file mode 100644 index 00000000..fe90f237 --- /dev/null +++ b/cmd/traceview/start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# traceview start script +# Usage: ./start.sh [port] + +ROOT_DIR="${1:?Usage: $0 [port]}" +PORT="${2:-19999}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN="$SCRIPT_DIR/traceview" + +if [ ! -f "$BIN" ]; then + echo "ERROR: traceview binary not found at $BIN" + exit 1 +fi + +# Kill existing traceview processes. +OLD_PID=$(pgrep -f "traceview" | grep -v $$ || true) +if [ -n "$OLD_PID" ]; then + echo "Killing old traceview (pid: $OLD_PID)..." + kill $OLD_PID 2>/dev/null + sleep 1 + # Force kill if still running. + kill -9 $OLD_PID 2>/dev/null || true +fi + +# Start in background. +nohup "$BIN" -port "$PORT" "$ROOT_DIR" > traceview.log 2>&1 & +NEW_PID=$! +echo "traceview started (pid: $NEW_PID, port: $PORT, root: $ROOT_DIR)" +echo "Log: $SCRIPT_DIR/traceview.log" diff --git a/internal/protocol/anthropic/adapter.go b/internal/protocol/anthropic/adapter.go index 5a09e767..0ec2045a 100644 --- a/internal/protocol/anthropic/adapter.go +++ b/internal/protocol/anthropic/adapter.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "log/slog" "strings" "sync" @@ -120,8 +121,16 @@ func (a *AnthropicProviderAdapter) anthropicToCoreRequest(req *MessageRequest) * if len(req.Tools) > 0 { coreReq.Tools = make([]format.CoreTool, 0, len(req.Tools)) for _, t := range req.Tools { + name := t.Name + if name == "" { + name = repairEmptyToolName(t.Description) + if name == "" { + slog.Warn("anthropic adapter: skipping tool with empty name", "description", t.Description) + continue + } + } coreReq.Tools = append(coreReq.Tools, format.CoreTool{ - Name: t.Name, + Name: name, Description: t.Description, InputSchema: t.InputSchema, }) @@ -310,12 +319,20 @@ func (a *AnthropicProviderAdapter) FromCoreRequest(ctx context.Context, req *for if len(req.Tools) > 0 { anthropicReq.Tools = make([]Tool, 0, len(req.Tools)) for _, t := range req.Tools { + name := t.Name + if name == "" { + name = repairEmptyToolName(t.Description) + if name == "" { + slog.Warn("anthropic adapter: skipping tool with empty name in FromCoreRequest", "description", t.Description) + continue + } + } schema := cleanSchema(t.InputSchema) if schema == nil { schema = map[string]any{"type": "object"} } anthropicReq.Tools = append(anthropicReq.Tools, Tool{ - Name: t.Name, + Name: name, Description: t.Description, InputSchema: schema, }) @@ -1040,6 +1057,16 @@ func (a *AnthropicProviderAdapter) coreCacheControl(c *format.CoreCacheControl) return cc } +// repairEmptyToolName attempts to infer a tool name from its description +// when the name field is empty. Returns the repaired name, or "" if +// inference fails — the caller should skip the tool. +func repairEmptyToolName(description string) string { + if description != "" && strings.Contains(description, "tool_search") { + return "tool_search" + } + return "" +} + // cleanSchema recursively removes nil values from a JSON schema map. // DeepSeek rejects null values in schema properties. // Empty maps are preserved as-is (e.g. properties:{}) to avoid corrupting From 7752a35e00da69c34c264b04e854722d066af615 Mon Sep 17 00:00:00 2001 From: binwang219962 Date: Tue, 9 Jun 2026 14:04:02 +0800 Subject: [PATCH 2/2] traceview: add expand/collapse for truncated content Replace static truncation with expandable text in all 7 truncation points. Full text is kept in a JS cache; expanding loads full text into DOM, collapsing restores truncated view to release DOM memory. --- cmd/traceview/index.html | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/cmd/traceview/index.html b/cmd/traceview/index.html index 77b61050..152fb79e 100644 --- a/cmd/traceview/index.html +++ b/cmd/traceview/index.html @@ -65,6 +65,8 @@ /* JSON inline */ .jk{color:#89b4fa}.js{color:#a6e3a1}.jn{color:#fab387}.jb{color:#cba6f7}.jl{color:#6c7086}.jbr{color:#94e2d5} .toggle{cursor:pointer;color:#89b4fa} +.expand-link{cursor:pointer;color:#89b4fa;font-weight:600;user-select:none} +.expand-link:hover{color:#b4befe;text-decoration:underline} .hide{display:none} .resizer{width:4px;cursor:col-resize;background:transparent;flex-shrink:0} @@ -271,7 +273,7 @@

Trace Viewer

html+='
'; if(Array.isArray(content)){ content.forEach(c=>{ - if(c.text) html+='
text: '+esc(c.text.length>600?c.text.substring(0,600)+'...':c.text)+'
'; + if(c.text) html+='
text: '+expandableText(c.text,600)+'
'; else if(c.type) html+='
'+esc(c.type)+': '+jsonToHTML(c,0)+'
'; }); }else{ @@ -305,7 +307,7 @@

Trace Viewer

} function renderInstructionsBody(text) { - return esc(text.length>2000?text.substring(0,2000)+'...[truncated]':text); + return expandableText(text,2000); } function sectionWrap(label, count, inner) { @@ -364,7 +366,7 @@

Trace Viewer

if(Array.isArray(content)){ content.forEach((c,j)=>{ if(c.type==='text'&&c.text){ - html+='
text: '+esc(c.text.length>600?c.text.substring(0,600)+'...':c.text)+'
'; + html+='
text: '+expandableText(c.text,600)+'
'; }else if(c.type==='tool_use'){ html+='
tool_use: '+esc(c.name||'?')+' id='+esc(c.id||'')+' '+jsonToHTML(c.input,0)+'
'; }else if(c.type==='tool_result'){ @@ -372,7 +374,7 @@

Trace Viewer

const maxLen=5000; html+='
tool_result: id='+esc(c.tool_use_id||''); if(c.is_error)html+=' [error]'; - html+='
'+esc(txt.length>maxLen?txt.substring(0,maxLen)+'...[truncated]':txt)+'
'; + html+='
'+expandableText(txt,maxLen)+'
'; }else{ html+='
'+esc(c.type||'block')+': '+jsonToHTML(c,0)+'
'; } @@ -392,7 +394,7 @@

Trace Viewer

html+=' system #'+i+' ('+text.length+' chars)'; if(s.cache_control) html+=' [cached]'; html+=''; - html+='
'+esc(text.length>2000?text.substring(0,2000)+'...':text)+'
'; + html+='
'+expandableText(text,2000)+'
'; html+=''; return html; } @@ -413,7 +415,7 @@

Trace Viewer

}else if(k==='usage'&&typeof obj.usage==='object'){ html+=sectionWrap('usage',fmtUsage(obj.usage),jsonToHTML(obj.usage,0)); }else if(k==='output_text'&&typeof obj.output_text==='string'&&obj.output_text){ - html+=sectionWrap('output_text',obj.output_text.length+' chars',esc(obj.output_text.length>2000?obj.output_text.substring(0,2000)+'...':obj.output_text)); + html+=sectionWrap('output_text',obj.output_text.length+' chars',expandableText(obj.output_text,2000)); }else{ html+='"'+esc(k)+'": '+jsonToHTML(obj[k],0)+'
'; } @@ -497,8 +499,7 @@

Trace Viewer

if(typeof obj==='boolean')return ''+obj+''; if(typeof obj==='number')return ''+obj+''; if(typeof obj==='string'){ - let s=obj.length>500?obj.substring(0,500)+'...':obj; - return ''+esc(s)+''; + return expandableText(obj,500); } if(Array.isArray(obj)){ if(obj.length===0)return '[]'; @@ -545,6 +546,10 @@

Trace Viewer

} function esc(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} +const textCache={}; +function expandableText(full,maxLen){if(!full||full.length<=maxLen)return esc(full||"");const id="exp_"+rand();textCache[id]=full;return""+esc(full.substring(0,maxLen))+" [expand]"} +function expandText(id,maxLen){const full=textCache[id];if(!full)return;const el=document.getElementById(id);if(!el)return;el.innerHTML=esc(full)+" [collapse]"} +function collapseText(id,maxLen){const full=textCache[id];if(!full)return;const el=document.getElementById(id);if(!el)return;el.innerHTML=esc(full.substring(0,maxLen))+" [expand]"} function extractText(content){if(!content)return'';if(typeof content==='string')return content;if(Array.isArray(content))return content.filter(c=>c.text).map(c=>c.text).join('\n');return String(content)} function rand(){return Math.random().toString(36).slice(2,8)}